Slice 10 from today-slices (d1126dd): the paste cache minted a fresh raster
array per new source frame (webcam ~30 keys/s ≈ 18MB/s LOH churn →
gen2 pauses that ate camera frames). Fix: _elementRasters — re-rasterize
INTO the element's existing array when geometry holds, so steady-state
allocation ≈ 0. The paste cache keys on (array+epoch) still protect correctness;
_elementRasters[element] tracks the element's current raster so stale keys can
never paste a mid-update array. MaxPasteEntries 48→256 (was a leak guard,
not an allocation stream). Regression test:
PasteCache_RingRecycledSources_ReRasterInPlace_WithoutAllocationChurn.
Build 0 warnings, 289 tests.
TASKS.md is now the index (status table, open items, research pointer).
33 files: 32 task files + 1 research facts file. The full take-saga
narrative and all design decisions are preserved verbatim; the catalog
makes the queue readable without opening every task body. Schema and
AGENTS.md updated to reflect the new layout.
User tested: 'didn't work at all, and broke additional crap.' Second failed remedy for
web-source-over-webcam dark composite -> AGENTS.md spin guard: no third guess; next attempt
requires external research (OBS/CEV browser-source transparency in recorded output, cited URL)
plus the blank-page falsifier. HANDOFF OPEN list updated: research-first step added, #11 noted
landed as d35823a.
take-19-2/take-20 'webcam black square under the web widget': the capture was
alpha-cropped (FindContentBounds) and that CROP was fed to the compositor, whose
UniformToFill zoomed the opaque content box to cover the whole element rect the
moment the widget drew any content (idle transparent page = full-frame crop = the
correct viewport, hence take-1-good/take-2-bad). OBS model verified: the page is a
fixed 1920x1080 canvas and the element rect is a viewport onto it — measure with the
alpha crop (preview/selection), hand the composite the FULL canvas on an 8-deep ring
+ Epoch (same identity discipline as camera/screen). Regression test:
Composite_FullCanvasWebSource_TransparentMarginsRevealWebcam (the reveal contract:
margin pixel = webcam, badge pixel = widget).
Roll-forward of today-slices 6fd1d9c onto the slice-8 base, two-loop hunk dropped.
Release counter (per-build GUID read as noise; +1 per commit from git rev-list,
baseline 241 -> #13, generated by GenerateBuildStamp; GUID demotes to startup.log).
Tests pinned to Label/#N >= 13; wordmark display test asserts the Label.
Flash fix (take 14 finding): consumer holds must never outlive depth x source
period — 4 slots at high refresh lap ~27ms vs a <=50ms compositor read, so a
recycled slot flashed its new frame over the lagged old one. All shared rings 4->8
(OBS/overlay precedent for ring discipline).
Camera producer now rotates an 8-deep ring + Epoch instead of a fresh ~3.7MB
array per device frame (110-220MB/s LOH churn); WebView2 capture reuses a canvas
scratch + 8-deep output ring + a reused WriteableBitmap instead of two fresh
arrays + a fresh bitmap per 10Hz tick. Paste cache stays identity-keyed (Epoch).
Take 11 (c10ce06c) validated the off-UI architecture: typical frames land
work ~10ms + wait ~6.8ms = 16.7 exactly on the deadline; 212/300 best yet.
The ENTIRE remaining gap is periodic 35-65ms render spikes that WORSENED
across the take (189 -> 147) — the signature of gen2 GC pauses. Biggest
churner is structural: the screen capture minted a fresh ~8.3MB byte[] per
DWM frame (~500MB/s of LOH), a producer OBS never does (it owns fixed
surface pools).
- ScreenCaptureFrameSource: 4-deep buffer ring with size-matched slots (a
<=17ms consumer cannot be lapped at 60Hz) + reused downscale row scratch.
- VideoFrame.Epoch: monotonic per producer frame. The paste cache keys on
array IDENTITY, so recycled arrays MUST be distinguished — epoch joins the
PasteKey. Producers handing fresh arrays leave it 0 (key unchanged effect).
- Stats print 'gen2 +N' per 5s window: next take acquits or convicts GC
without another guess (rule: prove the stage).
- Test (the ONE): PasteCache_RecycledArrayWithNewEpoch_ReRasterizes_NotStaleHits
— same array, new content, bumped epoch; fails on the old key by
construction. 37/37 compositor/pump, clean build.
- Next suspect if gen2 stays hot: the 10Hz WebView2 capture (full-canvas PNG
decode + fresh arrays on the UI thread) — recorded, untouched.
Creator audio ask queued in the same working session (+40% post-mix master
gain before the -1dBFS limiter) lands as its own commit next.
Take 10 (59a02a5b, slice 6) finally produced a self-contradicting stat: render
22.4ms + submit 2.5 against a 16.7ms deadline, yet avg wait 10ms — a rebasing
pacer CANNOT sleep after a blown deadline. The wait was queue time: StartAsync
fires from a UI command handler, and async continuations re-capture the current
SynchronizationContext — the 'WPF-free, hermetic' frame pump had been rendering
ON THE DISPATCHER behind the live preview the entire starvation saga. OBS keeps
obs_graphics_thread/video_thread off-UI for exactly this reason (dedicated
threads; see docs.obsproject.com/backend-design 'Libobs Threads').
- FramePump: _pumpTask = Task.Run(() => PumpAsync(...)) — null context inside,
every continuation stays on the pool.
- Audited, not ignored, what that exposes: StaticPixelCache.Get now locks (pool
miss-decodes raced UI callers); ChatOverlayLayer.RenderFrame checks its cache
off-thread but marshals the rare raster MISS to the dispatcher (DrawingVisual
+ RenderTargetBitmap are UI-thread objects) and re-validates there; pump
events already marshal in the VM.
- GCLatencyMode.SustainedLowLatency for the pump's life (restored in finally).
- Stats gained 'worst render Xms' — bimodal averages hid per-tick spikes.
- Webcam routes through the paste cache (the IsOpaque bypass re-sampled ~156k
px every tick even between identical device frames).
ONE integration test: Pump_Produces_OffTheStartingContext — an inline-pumping
SynchronizationContext makes the old construction run the resolver on the
starting thread by capture; the loop must never. 70/70 per-class green, clean
build 0 warnings. Docs same commit (ai.md slice 7, TASKS take-11 gate,
MyMistakes #6, HANDOFF). take 11: ~300/300 + honest wait -> saga closed,
Unit B (two-line top bar spec, fully captured) starts.
Take 9's numbers were decisive: paste cache moved work to ~25ms/frame but the
period stayed ~37ms. The missing ~12ms per tick is Task.Delay rounding every
sub-tick request up to the Windows system-clock tick (~15.6ms default —
documented: learn.microsoft.com/en-us/dotnet/api/system.threading.tasks.task.delay).
A frame finishing 3ms early requested 3ms and slept 15.6. Producer capped at
~27fps no matter how fast the compositor got — which is why two real render
fixes read as 'zero change' in playback. Game-loop/OBS canon for this
(stackoverflow.com/questions/5441464; learn.microsoft.com/en-us/windows/win32/
api/timeapi/nf-timeapi-timebeginperiod): raise the timer resolution for the
session, sleep only the bulk of the remainder, and SPIN the last ~2ms across
the deadline.
- FramePump: timeBeginPeriod(1) on entering the pump loop, timeEndPeriod(1) in
the finally; pacing = bulk _pacingDelay(ahead - 2ms) + bounded Thread.SpinWait
tail; blown deadlines rebase unchanged (never burst).
- Stats now report avg wait: render+submit+wait must equal the period — the
accounting is closed, no stage can hide in an unmeasured gap again.
- Webcam dropped its IsOpaque paste-cache bypass: it re-sampled ~156k px every
tick even between identical device frames; cached paste beats the sampler on
hits, costs the same on misses.
52/52 per-class green (pacing + pixel suites unchanged — output byte-stable),
clean build 0 warnings. Docs same commit (ai.md slice 6, TASKS.md take-10
gate, MyMistakes #3 + renumber, HANDOFF). User's top-bar spec remains next in
queue (Unit B) — re-sent many times, captured, no open questions.
The stamped build settled what slices 3-4 could not: chat cache works (resolve
~0.0ms) but render stayed 26-27ms -> 124-135/300. The cost was the compositor
re-rasterizing EVERY layer every tick: this Live scene re-samples chat (159k) +
web widget (271k) + image (95k) + cam (156k) ~ 680k px @ ~38ns — for layers
whose pixels do not change between chat/web/cam updates.
OBS shape: cache the surface, paste per tick. BlitCachedLayer rasterizes a
non-opaque layer ONCE into an element-space, transparent-based frame keyed by
(source-array identity, src W/H, ceil'd dst rect, round, mirror), then pastes:
integer position, row alpha-blend, opacity applied at paste. Producers hand out
fresh immutable arrays -> array-identity keys cannot serve stale content; dict
bounded (48, clears whole). Drag/opacity live in paste params, not keys, so
editing stops triggering resamples too. Opaque backdrop keeps the memcpy path;
the webcam keeps the direct path via its IsOpaque flag (revisit if take 9 is
borderline).
ONE integration test: PasteCache_RepeatRender_IsByteIdentical_And_ContentChange-
Propagates (byte-exact raster-vs-paste incl. round-clip margins, new-array
propagation); existing pixel suite guards sampler semantics. 85/85 across
compositor/pump/chat/capture/session classes, clean build 0 warnings. Also:
BuildStampTests.cs was written last commit but never staged — its own scope-check
slip, added here (the run had used the on-disk file; tracked now).
Docs same commit: ai.md slice 5 + stale 'general path only 130k' claim corrected,
TASKS.md take-9 gate, MyMistakes recipe (prove the stage; a fix that doesn't move
the stat wasn't the bottleneck), HANDOFF. take 9 expectation: 300/300, render
<= ~8ms -> saga closes, Unit B starts.
Take 6 measured render 35-41ms — WORSE than take 5's 25.5 — and the run could
not be attributed to a binary: exe mtime != build contents (incremental builds
serve stale exes; a source edit without rebuild is a silent old binary). Three
takes of a perf saga had been judged against builds nobody could prove.
- ytLive.csproj GenerateBuildStamp target: fresh GUID per compile (writes
obj/BuildStamp.g.cs -> Helpers/BuildStamp.Id/BuiltLocal). Deliberately defeats
incremental lies: every 'dotnet build' recompiles the app project.
- Wordmark shows the id as a superscript (TopBar.xaml, x:Static, 9px grey
BaselineAlignment=Superscript); startup.log records 'Build <id> (compiled
<time>)' so every take is cross-readable with the visible UI.
- FramePump stats split the tick: 'avg render Xms (resolve Y), avg submit Z' —
the resolver is timed separately (wrapper resolver on per-tick paths; bake
keeps the raw one) so take 7 names the hot half of 'render' with data.
- Fixed a latent transition-clock bug found on the way: lastTick now restarts
every frame (the branch rework had restarted it only during transitions,
letting a transition begun after idle complete instantly on its first Tick).
ONE integration test family: BuildStampTests (unit: shape) +
BuildStampDisplayTests (RealApp, namescoped FindName on TopBar proves the
wordmark SHOWS the id). 47/47 per-class green, clean build 0 warnings. Docs
same commit. User's top-bar/session spec (re-sent twice) + settled Q&A
decisions folded into HANDOFF Unit B — next work unit after take 7 verdict.
Take 5: render 58.9 -> 25.5ms (138/300, still ~2.2x). The blits were fixed; the
resolver was not: ResolveOutputFrame -> RenderChatBox ran a FULL WPF raster
(FormattedText + RenderTargetBitmap + CopyPixels + channel swap) EVERY tick
whenever the chat buffer was non-empty — and the buffer survives sessions, so
even a signed-out record-only take paid it. Established answer (OBS text
sources): re-render on change, blit the cache every tick.
ChatOverlayLayer: content version bumped from Messages.CollectionChanged
(covers adds, the 500-cap removal, the fade Clear from any caller) + a config
key (size + all Chat* appearance props); RenderFrame returns the cached
VideoFrame by identity until either changes (compositor only reads cached
frames). Conservative ordering (version latched BEFORE render) makes a
mid-render message re-render next tick, never serve stale.
ONE integration test: ChatOverlayLayerCacheTests (RealApp, real renderer):
Same() for unchanged inputs, NotSame() on message/config change, null on
empty. Full regression green (62 across touched classes), clean build 0
warnings. Accepted cost pending take 6: one ~15-25ms tick per arriving
message; if live-chat bursts sag n/300, next slice = debounced off-tick
re-render. Docs same commit: ai.md pipeline section, TASKS.md TASK 18,
MyMistakes recipe (raster-on-change + session-surviving-buffer trap),
HANDOFF (take 6 -> then Unit B, spec unchanged).
Two defects made the producer 17x slow (37s record -> 2.1s/127-frame file,
rawvideo stamps by arrival): FramePump slept the FULL interval after each
render (period = render+submit+interval) and SceneCompositor did per-pixel
float sampling + Math.Round blends over all 2.07M master pixels, scanning the
whole destination per overlay (258ms avg render vs 1.5ms submit).
Both solutions are established, not invented — researched before coding per
the derivative-work rule:
- deadline pacing: OBS libobs/media-io/video-io.c video_thread (nextTick +=
intervalTicks, sleep only the remainder, rebase on overrun, never burst)
- row blits: libyuv pattern (BSD-3, chromium.googlesource.com/libyuv/libyuv)
— 1:1 aligned identity fast path, per-pixel alpha branch, integer
fixed-point blend, overlay clipped to the intersection rect, skip the dead
black pre-fill when the backdrop covers
ONE integration test: Pump_Paces_To_The_Deadline_Compensating_Render_Cost
(lands after a fake-seam lesson: pacing fakes must await, not complete
synchronously, or the pump loop runs inline on StartAsync and hangs vstest).
Clean build 0 warnings; FramePumpTests 10/10, SceneCompositor/SceneGraph/
SocialBar/StretchMath 20/20. Docs same commit: ai.md pipeline section,
TASKS.md TASK 18 (webcam-in-output + rename modal verified from take 3),
MyMistakes recipe, HANDOFF rewritten. Take 4 pending on the user's machine.
- Modes: creator ruling 2026-09-01 — the VOD is the copy; dual encode degrades both
outputs on mediocre hardware ('we're not them'). ai.md 'cheap on NVENC' claim
retired; TASK 18 three-modes -> two; pills-become-radios noted as PENDING code.
- Design Principle gains 'we're not them' (hardware realism) as its fifth bullet.
- TASK 34 scope: countdown+notify live scheduling is the whole story — 'going live is
our schedule'. Scheduled playout discussed & DECLINED; premiere/upload + playout +
simultaneous-record-stream added to the closed out-of-product table.
- TASK 22: SYNC slider provenance restored — creator-requested (OBS delay-filter fix,
native). Placement question stays open; capability does not.
- MyMistakes: the provenance rule (a fact without its who/why becomes a future argument).
Docs only — zero code in this commit, per creator instruction.
Map corrections: ai.md encoded the webcam lookup bug verbatim (GetLatestFrame(WebcamId))
— code wins, line rewritten; frame-pipeline section gains the rawvideo arrival-stamping
lesson + stats log; TASK 18 status = running-app verification IN PROGRESS with take-3
pending on the pump starvation; state-model paragraph matches the new always-Start bar.
Take two (2026-09-01) three confirmed defects, all from creator feedback + the
new resolver logging:
1. NO WEBCAM IN OUTPUT: WebcamSceneConfig.WebcamId carries the identity GUID;
CameraManager keys sessions by DEVICE id — GetLatestFrame(guid) returned null
forever, the compositor silently dropped the layer while the preview (bitmap
path) looked fine. The resolver logging added in 8dcaee0 caught it red-handed.
DeviceKeyForWebcam maps identity->device through the _webcam singleton (identity
mismatch / unknown ids pass through). Test: WebcamOutputKeyTests.
2. START BUTTON VANISHED AFTER STOP: my pills-clear ruling met the old
ShowPrimaryStartButton gate (needed IsConnected or a lit REC pill) — signed-out,
pills-off = blank top bar, no session reachable. Start is now ALWAYS the idle
face; unarmed Start records (local needs no account — no dead-end no-op); the
Sign In button folds into Start's context menu ('Sign in to YouTube', shown while
disconnected) with a dedicated SignInCommand. SessionTeardownTests extended:
stopped session must leave a reachable Start.
3. TOP BAR ORDER (creator spec): [sign light] REC [pill] [sign light] ON-AIR [pill]
— each reality lamp now sits in front of its own intent switch (was: two pills,
then two orphaned dots). Sign labels keep the shared StatusSignText style.
Tests: WebcamOutputKey, SessionTeardown, FramePump, WebcamMenuGate, GlobalHotkey,
BroadcastPullOut — 14/14 across the six classes, clean build 0 warnings.
Take-two forensics: rawvideo carries no per-frame timestamps — ffmpeg stamps frames
by ARRIVAL at the declared fps, so a 2fps producer yields a 30x time-lapse, 0.6s-long
'60fps' file with zero errors anywhere (explains the creator's accelerated webcam
motion + truncated length observations). The pump now logs frames-produced-vs-target
per 5s with the avg render/submit split — take three names the slow stage instead
of us theorizing. FramePumpTests 9/9. (Multi-commit working unit: remaining declared
files land in the next commit.)
First real recording (2026-09-01): ffmpeg died at ~0.9s mid-session with ZERO log
lines while the pump fed a corpse for 9 more seconds. Three blind spots closed:
- OnStderrLine discarded everything non-progress: ffmpeg's actual words (startup
config, warnings, the reason it quit) now go to AppLog prefixed 'ffmpeg:' (400-char
cap so a \r-choked blob can't flood).
- Unexpected exit only fired ProcessFailed when code != 0 — a CLEAN exit vanished
silently. Any exit we didn't request is now logged and fired, any code.
- ExitCode read raced StopAsync's Dispose (tonight's 'No process is associated'
line) — guarded.
- VM output resolver logs once/5s when a webcam element resolves to a null frame
(preview showed the cam, recording didn't — the layer skip was invisible).
FfmpegEncoderTests + FramePumpTests 21/21 against the new code, 0 warnings.
Creator report 2026-09-01: with the desktop slider dragged to 20%, the game bar's
meter stayed pegged yellow/red. Root cause: the mic meter's formula (level x volume)
was cloned to the game bar WITHOUT the x-volume factor, and the clone inherited a
false premise — that WASAPI loopback capture tracks the endpoint volume. Tonight's
observation disproves it (the tap is pre-endpoint-volume), so both the meter (display)
and the mix (AudioGainProvider.LoopbackGain, fixed in 5ead064) must scale it themselves.
ai.md's two 'loopback scales with the slider' sentences corrected in the same pass.
Test seam: VolumePushOverride (internal static, mirrors LayoutPathOverride pattern) so
the test never hijacks the machine's real volume. GameMeterHonestyTests (RealApp +
temp DB): unity -> hot/red, 20% -> ~1/5 width/green, push observed.
The creator-declared known failure (Mix_HonorsProviderGains) and AudioPipelineTests'
standalone hang shared ONE root cause, born in TASK 22: AudioMixer.FillAndMix
dereferenced _delayedMix (declared float[]? , never assigned) as delayed.Length —
every live-mix tick NRE'd before the pipe write, so NO audio ever reached the wire
(tests starved -> hung/fail; app -> swallowed catch logged only ex.Message, 10ms
flood). Now: ctor-allocated + null-check, catch logs WITH stack via AppLog and is
throttled 5s, and a cancelled token breaks out before logging.
Bonus contract fix: AudioGainProvider.LoopbackGain returned unity on a disproven
premise ('loopback scales with endpoint volume'). The creator's tonight observation —
volume at 20%, meter pegged — proves the WASAPI tap is pre-endpoint-volume, so the
mixer must multiply by GameAudioVolume for stream honesty (this is what ai.md always
specified; the 'failing' test encoded the same and was RIGHT).
AudioPipelineTests: 25/25 in 26ms, standalone, no hang. Known-failure count: ZERO.
ai.md tests paragraph rewritten (no more suite-total claims, both ex-'knowns'
explained); TASK 22 regression recorded; MyMistakes: the known-failure-label rules.
Creator ruling 2026-09-01 (stuck REC pill after the failed first recording):
- StopStream clears RecordPillOn + OnAirPillOn — intent resets with reality.
- OnFramePumpFailed: dispatcher-marshalled FULL rollback via StopStream (was:
toast + Error-status only when live — record-only sessions zombied with
IsRecording=true over a dead encoder; the 20:34 attempt proved it). Toast copy
says 'Recording stopped' vs 'stream pipeline stopped'.
- Same zombie class in the three go-live prep failure branches: StreamStatus.Error
limbo replaced by StopStream() (audio loop + recording + pills unwind; a created
broadcast still gets the close-out).
- AudioMixer.StopLive made explicitly idempotent (Stop() twice, rollback paths that
never reached StartLive).
- Seams: OnFramePumpFailed + IsRecording setter internal (InternalsVisibleTo; test
pattern mirrors LayoutPathOverride).
ONE integration test: SessionTeardownTests (real window + temp DB: pump death with
lit pill -> no zombie, no End button, Offline). AudioPipelineTests confirmed to hang
STANDALONE (pre-existing, the declared-known audio class) — ai.md test-count
paragraph corrected to stop claiming a suite total that cannot currently be measured.
The specced 'End stream -> transition(complete)' call never existed: stopping relied
entirely on enableAutoStop (viewers sat on a frozen stream-offline for ~a minute, VOD
finalized late). Found during the 2026-09-01 recording-verification pass while the
creator asked 'if there's proper close-out info yt needs, we'll provide it?'
EndBroadcastAsync POSTs liveBroadcasts/transition?broadcastStatus=complete&id=..&part=status,
called after the pump stops (RTMP EOF first) and only when a live session had a broadcast —
record-only stops stay offline. invalidTransition/410 (autoStop already ended it) is logged
and returned as an error string, never thrown: a stop must never fail over close-out.
ONE integration test (URL shape + never-throws on 403). ai.md/TASKS.md design lines marked
SHIPPED with the map-lie note.
Ref: https://developers.google.com/youtube/v3/live/docs/liveBroadcasts/transition
The 2026-08-09 pin was a DAILY build; BtbN retention aged it out and the cold-cache
download 404'd on the creator's first native recording attempt (2026-09-01,
startup.log). Re-pinned to the MONTH-END build autobuild-2026-08-31-13-27
(N-126342, lgpl-shared win64 — 2-year retention; tag+variant recorded in TASKS.md
per the licensing rule). Verified alive: HEAD 200 + zip contents (bin/ffmpeg.exe,
bin/ffprobe.exe, 7 libav DLLs) match the name-agnostic extractor.
Also closes the wrap-gap: HttpRequestException escaped the locator untouched,
surfacing a raw 'Response status code... 404' from the frame pump; now wrapped in
IOException with the refresh-pin-or-install-ffmpeg message. FfmpegLocatorTests 7/7
(one updated, one added for the 404 case).
User launch (2026-09-01 19:07) NRE'd in MainViewModel ctor:
1. SceneGraph (TASK 31) was 'null!'-declared, assigned mid-ctor, but Scenes is touched
~120 lines earlier — field-initialized now.
2. LeftPanel extraction (c9fd1bd) moved StaticResource users (EyeButton/EyeIconStyle)
into a UserControl while the styles stayed window-scope — invisible at parse time;
moved to Themes/Controls.xaml (app scope, the existing rule). Full audit: these were
the only two offenders (grep of Controls/*.xaml StaticResource keys vs app dictionary).
3. RoundClipInteractionTests — the second 'known failure' the map never explained: it
was two stale-test layers (window.FindName across the new UserControl namescope +
VisualTreeHelper.HitTest, which returned the IsHitTestVisible=False WebViewHostPanel
overlay for EVERY point; UIElement.InputHitTest — the real input pipeline — shows the
corner IS grabbable in both Traditional and Round). Test fixed, no product bug.
Verified: clean rebuild 0 warnings; app boots (log shows full MainWindow loaded; user
clicked + closed, zero new exceptions; real DB Webcam row = the genuine C920, untouched);
RoundClip + 6 RealApp classes pass natively per-class.
Docs: ai.md known-failure note → 246/247 (audio only); TASK 31 verification paragraph
corrected ('cannot run headless' overstated — per-class Windows-host vstest runs them);
MyMistakes: InputHitTest-vs-VTH recipe + namescope/app-style + shared-log facts.
Spin-guard citations: WPF Visual Tree Overview (InputHitTest vs VisualTreeHelper hit
semantics) + XAML namescope docs, learn.microsoft.com.
All headless-testable TASK 21 decoder/mechanism slices shipped; the remaining UI
picker slice (AddMedia command + file dialog + Acquire/Release + Source.MediaIsLooping
-> IMediaFrameSource.Looping) is a GUI feature and is spec'd in HANDOFF for native
Windows build + verification. TASKS status + HANDOFF updated.
- IMediaFrameSource gains bool Looping.
- MediaVideoSource ctor takes Func<IDecodeProcess> processFactory instead of a
single IDecodeProcess: a System.Diagnostics.Process can't be re-Start()ed, so
each loop pass creates a fresh decoder. Decode wrapped in do-while(Looping):
restart on natural EOF instead of raising Completed.
- Production wiring (MainViewModel media factory): passes the process factory
AND FfmpegFrameRateProbe -- closes the slice-2b gap where production had no
probe and therefore no pacing.
- Tests: loop test (single frame re-emits across passes, Completed only when
loop cleared); fakes updated for the new interface member. Media tests 12/12,
build 0 warnings.
Wiring Source.MediaIsLooping into the flag needs a manager-level per-path loop
provider -> lands with the UI-picker (acquisition) slice.
Derivative reference: looping media by restarting decode on EOF, standard in
playback/overlay tooling (OBS media source repeat).
- MediaVideoSource takes optional IFrameRateProbe? + Func<TimeSpan,CancellationToken,Task>? delay
seams (default Task.Delay); probes FPS once in RunAsync, delays by 1/fps after each
emitted frame. No probe/unknown fps -> no pacing (ffmpeg pipe backpressure throttles).
- Test: MediaVideoSource_PacesFramesByProbedFps (fake probe returns 1000fps + recording
delay; one delay per frame ~= 1ms). Media tests 5/5, build 0 warnings.
Derivative reference: per-frame delay pacing of decoded output, standard in media playback.
- FfmpegFrameRateParser (pure): prefers avg_frame_rate= then r_frame_rate=,
rational N/N/M, unknown/0 -> null.
- IFrameRateProbe + FfmpegFrameRateProbe: derives sibling ffprobe.exe from the
located ffmpeg dir, reuses the IDecodeProcess seam for the ffprobe subprocess
text; null if ffprobe absent.
- FfmpegLocator now also extracts ffprobe.exe (ProbeFileName) from the pinned
archive, conditional so old caches without it degrade to no pacing.
- Tests: 6 pure parser units + 1 probe integration via fake locator/process;
FfmpegLocatorTests still green. 15/15, 0 warnings.
Pacing (probe->delay) is slice 2b. Derivative reference: standard ffprobe
avg_frame_rate probing used across OBS/media tooling.
Wire the MediaVideoSourceManager into the live pipeline (new partial
ViewModels/MainViewModel.Media.cs mirrors Webcam/Background partials):
readonly _mediaManager assigned in the core ctor (decode at 1920x1080 master,
dispatcher-coalesced preview), OnMediaPreviewBitmapChanged adopts the shared
bitmap onto every IsMediaSource with a matching MediaPath, a
Source { IsMediaSource, MediaPath } case in ResolveOutputFrame feeds
GetLatestFrame, and the manager is disposed on shutdown.
Source.DisplaySource now routes VideoImageSource for MediaSource (Source.cs),
so media previews/canvas show decoded video.
Compositor needs no change: BlitContent UniformToFill-scales any frame to the
element rect. Decoder/manager/seam derived-work mirrors ScreenCaptureManager
(citation in commit for slice-step-3).
Test (one unit for this change, mirroring the live-capture case):
Source_DisplaySource_IsVideoImageSource_WhenMediaSource. 14/14 media +
DisplaySource tests pass, build 0 warnings. Docs (TASKS/HANDOFF/ai.md) updated.
Adds the codec-agnostic decoder half of the media source. Spawns ffmpeg
with -f rawvideo -pix_fmt bgra (reusing the already-shipped ffmpeg via
IFfmpegLocator) and drains the raw BGRA stdout pipe into VideoFrames.
- Services/RawVideoFrameReader.cs: pure rawvideo BGRA stream -> frames
(partial reads kept across Feed; no ffmpeg needed to test)
- Services/IDecodeProcess.cs + FfmpegDecodeProcess.cs: binary-stdout
subprocess seam, mirror of the encoder's IEncoderProcess
- Services/MediaVideoSource.cs: owns the decode, raises FrameReady/Completed
- MediaVideoSourceTests: 3 pure reader + 1 integration (fake decode
process through the real source loop, frames in order)
Reference (external scan): ffmpeg rawvideo pipe decode is the canonical
codec-agnostic frame feeds pattern (ffmpeg docs -f rawvideo; how OBS/media
pipelines push frames to a compositor). Verified: 4/4 tests, build 0 warnings.
Native-FPS pacing + resolver/compositor wiring are the next slice.
- Add ElementKind (Static/Dynamic) to SceneElement base; Source/WebcamSceneConfig classify
- Services/SceneGraph.cs: owns the Scenes collection (ViewModel's Scenes delegates to it),
the element mutation surface (Add/Insert/Remove/Move, each invalidating the bake), and the
queries that were scattered LINQ (GetBackground/GetWebcam/GetChatBoxes/GetSplitPoint/IsStatic)
- SceneCompositor: split-aware BakeStaticBase + CompositeLayers + Render(.., staticBase, split);
builds/caches the static base below the split point in source-rect space
- FramePump: optional SceneGraph -> optimized bake+composite path; falls back to full render
- MainViewModel: routes element mutations through the graph; invalidates the bake on static
layout/opacity/visibility/useDefaultBackground changes and after background heal/Ensure
- Integration test SceneGraphTests.BakedStaticBase_WithDynamicLayer_CompositesCorrectly
Derivative survey (mandated): tried before writing — OBS does per-source opacity/visibility
caching and static-scene baking; this mirrors OBS's 'cached static source' optimization.
3 documented defensive deviations from the spec (ChatOverlayLayer stays decoupled;
background helpers stay VM-static for direct testability; full facade peel deferred post-1.0)
in TASKS.md + ai.md. Scope check passed; clean build 0 warnings.
ai.md: Key patterns gains the 'true decomposition beats partial-shuffling'
rule + the glue-vs-component diagnostic (count OnPropertyChanged/SetProperty;
extract only zero/low-glue cohesive state blobs like chat's; leave binding glue
over already-extracted services alone; line count is a guideline not a goal).
ViewModels table row notes Chat.cs is now a thin facade over ChatOverlayLayer.
HANDOFF: everything through Commit G is pushed (origin/main=85893ea); the
remaining big decomposition is the scene-graph segment, flagged as design-needed
(not an unsupervised peel).
The first true decomposition of the 4105-line god-object, not another partial
shuffle. MainViewModel.Chat.cs 194 -> 44 lines of thin delegation; all chat
behavior now lives in a self-contained Services/ChatOverlayLayer.cs (199):
- Owns the message buffer (Messages), ChatBoxRenderer, fade + mock-preview
timers, per-source preview renders, and the live-output RenderFrame path.
- The VM keeps only the binding surface: ChatMessages delegates to
_chatLayer.Messages (so XAML ItemsSource + LeftPanel CollectionChanged hold),
CanAddYouTubeChat / ShowChatInactiveMessage stay computed on the VM
(OnPropertyChanged raised from VM setters + XAML-bound), and thin forwards.
- Scenes handed in as args (no Func seam), so the layer owns no scene graph.
An AI reading ChatOverlayLayer.cs now sees the entire chat feature in one
self-contained unit. Zero behavior change; build 0 warnings; 246 pass, only the
2 known failures. Docs: ViewModels/index.md tracker updated in same commit.