fix: FramePump paces one frame per deadline slot — duplicate-on-lag, never skip (slice 15)

ty-1723/1726 device takes showed accelerated playback + audio tail cut-off:
a render overrun (~35ms vs the 16.6ms slot) SKIPPED the missed slots (slice 10's
freshness choice), so a 60fps-authoring pump wrote one frame per 35ms into a
60fps container — 1723: 697 frames/11.62s vs 11.84s audio; 1726: 163/2.72s vs
2.93s, video ending 0.21-0.24s early.

OBS never leaves a wall-time hole: the video thread emits one frame per tick and
a lagging producer DUPLICATES the newest frame ("lagged frames due to rendering
lag/stalls" — obs-output.c; "If the video frame queue is full, it will duplicate
the last frame" — docs.obsproject.com/backend-design). The pump's submit is now a
bounded catch-up over the missed slots (while now >= nextTick), fresh on the first,
repeated after — duration == wall, judder not fast-forward. Safe because Channel.
TryWrite never blocks (the take-9 smear was the blocking pipe-write; each emit is
nanoseconds). Burned frame index moved inside the loop: every emitted slot carries
its own +1 (also fixes the old unconditional pre-gate bump that gapped the judge
sequence on non-submitting fast-render iterations).

Good Dog test: Pump_Overrun_Renders_EmitsEverySlot_NotSkipped (60fps, 35ms render
cost, asserts >=0.65 of the wall slots emitted). 294/294 green, 0 warnings.

No push — web/A/V work is commit-local until greenlight.
This commit is contained in:
2026-09-14 17:40:59 -07:00
parent 394d86b94c
commit 76f51e6f4e
5 changed files with 176 additions and 95 deletions
+60 -65
View File
@@ -1,96 +1,91 @@
# HANDOFF — 2026-09-14 (web capture REDESIGNED: composition-capture, PNG polling + CaptureScheduler deleted — NOT yet verified on device, NOT pushed) # HANDOFF — 2026-09-14 (composition capture DEVICE-VERIFIED ✓; slice-15 pacing fix committed locally — A/V re-measure next)
## Branch / Commit State ## Branch / Commit State
`main` HEAD = **`c01206f`** (web-frame-capture redesign — committed LOCALLY, **NOT pushed**; web work `main` HEAD = **slice-15 commit** (FramePump duplicate-on-lag pacing — committed LOCALLY, **NOT
commit-locally-until-greenlight). Before it: `b22d08e` (signed audio-sync, already on origin/main). pushed**; web/A/V work stays commit-local until greenlight). Before it: `c01206f` (composition
capture redesign, also local/unpushed), before that `b22d08e` (signed audio-sync, pushed).
Working tree **clean**. Working tree **clean**.
## ⚠️ Branding (2026-09-14, creator-corrected): product = **llamacasty**, internals = ytLive ## ⚠️ Branding (2026-09-14, creator-corrected): product = **llamacasty**, internals = ytLive
The product is **llamacasty**; the repo path, csproj `AssemblyName`/`RootNamespace`, DB/log paths The product is **llamacasty**; repo path, csproj `AssemblyName`/`RootNamespace`, DB/log paths
(`%APPDATA%\ytLlive\...`), and most code names are the legacy **ytLive/ytLlive**. User-facing (`%APPDATA%\ytLlive\...`), and most code names are the legacy **ytLive/ytLlive**. User-facing
language must say "llamacasty"; code/assembly/repo names stay ytLive. Full detail in language says "llamacasty"; code/assembly/repo names stay ytLive. See `ai.md` → Brand.
`ai.md` → Brand → "Product name vs repo/assembly branding".
## 💥 The web-capture take history (why this redesign exists) ## ✅ DEVICE-VERIFIED — composition capture looks good; then the A/V defect surfaced
The recording was 60fps but web widgets ran at ~1/6 speed: capture was a `CapturePreviewAsync` PNG The creator ran real takes off the `c01206f` build and reports the video looks **very good** — the
poll; every full-HD encode+decode cost **35–165ms** so the session budget capped real cadence at Windows.Graphics.Capture web path is confirmed on device (open item closed). BUT the same takes
~20Hz — a 60fps widget still juddered. Slice 11's `CaptureScheduler` (30Hz) was the wrong lever: showed the A/V defect we have seen before: **both desktop and webcam playback are accelerated, the
the fix is frame-driven, not faster polling. audio "gets speedier", then cuts off at the end.**
## 🔬 Committed locally as `c01206f` — composition capture (NOT pushed) ## 🔬 Sliced and measured (evidence)
**What:** web sources now render through `CoreWebView2CompositionController` into With the A/V recipe (`MyMistakes.md` → "Measuring audio-video A/V sync" + `/tmp/opencode/avsync.py`):
`Windows.Graphics.Capture` (the Flutter `webview_windows` / `WebView2CompositionControl`
mechanism) — frame-driven at the renderer's pace instead of polling PNGs.
**Pipeline (per session):** `CreateCoreWebView2CompositionControllerAsync(appHwnd)` → - `ty-20260914-1723-0000-2.mp4`: 697 video frames @60fps = **11.62s** vs audio **11.84s**.
`RootVisualTarget` = a `RelativeSizeAdjustment=1,1` child under a 1920×1080 root `ContainerVisual` - `ty-20260914-1726-0000-2.mp4` (clap take): 163 frames = **2.72s** vs **2.93s** audio. Video ends
on one `Compositor` (CoreMessaging DQ P/Invoke recipe — see ai.md Slice 14 / MyMistakes) → 0.21–0.24s before audio → the cut-off. Clap: audio env peak 1.655s vs video motion 0.75–1.03s;
`GraphicsCaptureItem.CreateFromVisual(root)` → free-threaded `Direct3D11CaptureFramePool` (2 cross-correlation lag **+0.667s** (audio late).
buffers) → `SoftwareBitmap.CreateCopyFromSurfaceAsync` (`BitmapAlphaMode.Straight`) → - `startup.log`: `FramePump stall: iteration 33-46ms (> 2× the 17ms interval): worst render 33ms …
per-frame `FindContentBounds` bbox → `CropBounds` → epoch'd ring → dispatcher-coalesced crop copy dropped 0`.
into ONE shared `WriteableBitmap`.
**Files:** NEW `Services/WebCaptureFrameSource.cs`; reworked `Services/WebView2Manager.cs` (internal **Root cause (slice 15):** slice 10's freshness choice skipped the overrun's missed slots — one
seam ctor `(Dispatcher, Func<string, IScreenCaptureSource>?)` for hermetic tests); DELETED fresh frame per ~35ms stall authored into a 60fps container = **accelerated playback**. OBS's
`Services/CaptureScheduler.cs`; `MainViewModel.Web.cs` (`InitWebView2()` no-arg), answer is duplicate-on-lag (`libobs/video-io.c`, docs.obsproject.com/backend-design): fill each
`MainViewModel.Streaming.Operations.cs` (three `SetCaptureInterval` hooks gone — verified zero missed slot by repeating the newest frame — duration == wall, judder never fast-forward.
remaining), `MainWindow.xaml(.cs)` (`InitWebView2()` moved ctor→**Loaded**; `WebViewHostPanel`
overlay deleted). `TransparentBackgroundScript` unchanged.
**Tests:** reworked `ytLive.Tests/WebView2ManagerTests.cs` — dropped the 4 control-size tests + ## 🔬 Committed locally — slice 15: one frame per deadline slot (duplicate-on-lag)
`CaptureScheduler_Drops…`; `FindContentBounds` tests moved to `WebCaptureFrameSource`; the ONE
integration test (`Frames_PublishCroppedPreview_And_CoalesceToLatest_CarryingCropBounds`) drives the
seam with `FakeWebSource` + background-STA `DispatcherPump`: crop-sized preview published once →
`CropBounds` + `IsOpaque=false` → back-to-back frames coalesce to latest. `RoundClipInteractionTests`
comment updated (WebViewHostPanel gone). Suite **293/293 green**, app build **0 warnings**. Docs
slant (ai.md Slice 14, HANDOFF, MyMistakes, TASK 17, Controls/ViewModels/Services indexes) updated in
the same working set.
## ⚠️ Open items on this change (before it is PUSHABLE) **What:** `Services/Encoder/FramePump.cs` submit is now a bounded catch-up —
`while (now >= nextTick) { submit latest composite; nextTick += intervalTicks; }` clamped to a
`deadlineNow` captured once per iteration. First missed slot gets the fresh composite, the rest get
repeats of it (OBS duplication) — recording duration == wall time under any render load, no
acceleration, no audio tail cut. The burned `_outputIndex` moved inside the submit loop (every
emitted slot gets its own +1; also fixes the old unconditional bump that gapped the judge sequence
on non-submitting fast-render iterations).
- **Device verification:** the composition path has never run against a real widget. Verify ~60fps **Good Dog test:** `ytLive.Tests/FramePumpTests.cs` → `Pump_Overrun_Renders_EmitsEverySlot_NotSkipped`
web animation in a take (also: the OBS de-throttle flags stay; hidden-page JS throttling is fixed (60fps, 35ms render cost, asserts ≥0.65 of the wall slots emitted — the old skip-pump wrote ~1/35ms).
by them, capture pacing is now renderer-driven). **294/294 green, app build 0 warnings.** Files: `Services/Encoder/FramePump.cs`,
- **No push yet** — web work is commit-local until the user says push. `ytLive.Tests/FramePumpTests.cs`. Docs in same commit: ai.md Slice 15, MyMistakes (supersedes the
slice-10 "no burst re-write" clause + latest numbers), this HANDOFF.
## ✅ SHIPPED + PUSHED — signed audio sync, −500..+500 (`b22d08e`) ## ⚠️ Open items (before PUSHABLE)
Positive = delay the mix (audio AHEAD — the `AudioSyncDelay` line, live-reactive). Negative = - **Device re-verify:** a take on the slice-15 build must play at REAL time (no acceleration, no
**advance** (audio BEHIND): OBS-style "eat the stream head" — `AudioMixer.StartLive` arms cut-off audio tail). Confirm via ffprobe: video stream duration ≈ audio ≈ container.
`_advanceSamplesRemaining = |N| ms`, `LiveLoopAsync` skips `min(budget, mixBuffer.Length)` off each - **Re-measure the true A/V offset** with a clap take now that video pacing is honest
write head while it lasts. Slider relabeled **AUDIO SYNC**, `−500..500`, locked while (`/tmp/opencode/avsync.py`). The +0.6s reading on 1726 was confounded by the 1.1x
live/recording (`IsEditMode`). One regression test: acceleration. If a real residual remains after the pacing fix, the backlog is the AUDIO pipeline
`StartLive_NegativeOffset_AdvancesAudio_ByDroppingTheStreamHead`. (mixer ring / advance), not video.
- **No push yet** — commit-locally-until-greenlight for web/A/V work.
Take `ty-20260914-1128-0000-2.mp4`: **offset ≈ +0.54s** (collapsed from +2.11/2.22s; residual was ## Open threads (carried)
the baked-in `SyncOffsetMs=300`, now 0 — confirmed via sqlite3). Both streams `start_time=0`.
## Open threads - Audio-silence verification — fixed (`724af14`); creator heard real audio.
- Webcam MJPG missing / ~10–14Hz, layer SortOrder, truncation-with-dynamic-scenes — queued.
- **Verify signed sync negative direction on device** (clap take with a negative offset). - Sync control user-doc tutorial — REQUIRED before 1.0 (creator directive; TASK 22).
- **Audio-silence verification** — fixed (`724af14`); creator heard real audio. - Signed A/V sync: verify the negative (advance) direction on device.
- **Webcam MJPG missing / ~10–14Hz**, layer SortOrder, truncation-with-dynamic-scenes — queued.
- **Sync control user-doc tutorial — REQUIRED before 1.0** (creator directive; TASK 22 file).
- **Device verify the composition web capture (this change).**
## Landmines ## Landmines
- testhost shares startup.log — filter by time. - testhost shares startup.log — filter by time.
- `cmd.exe /c "taskkill /F /IM ytLive.exe"` (WSL double-slashes mangle) before rebuilds. - `cmd.exe /c "taskkill /F /IM ytLive.exe"` (WSL double-slashes mangle) before rebuilds — a live
app process locks `ytLive.exe` and the apphost copy fails (MSB3021, seen today).
- 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. only `./scripts/verify.sh "<files>"`'s clean build counts. Building `ytLive.csproj` alone does
NOT rebuild `ytLive.Tests.dll` — run the Tests csproj before `vstest`.
- 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` has the **audio/video sync measurement recipe** AND the **composition-capture - `MyMistakes.md` has the **A/V sync measurement recipe**, the **deadline-pacing** lessons
CoreMessaging DQ recipe (2026-09-14)** — grep before re-deriving. (item 1 + slice 15), and the **CoreMessaging DQ recipe** — grep before re-deriving.
- sqlite3 lives at `/home/gramps/android-sdk/platform-tools/sqlite3` (WSL) for the DB at - sqlite3 at `/home/gramps/android-sdk/platform-tools/sqlite3` for
`/mnt/c/Users/gramp/AppData/Roaming/ytLlive/ytLlive.db`. `/mnt/c/Users/gramp/AppData/Roaming/ytLlive/ytLlive.db`.
## Next step ## Next step
From this dirty state: run `./scripts/verify.sh "<all changed files>"`, review the diff, commit Have the creator record a clap take on the slice-15 build → ffprobe durations (video == audio ==
locally (web work — NO push, per standing directive). Then a device take to verify ~60fps web wall) + `/tmp/opencode/avsync.py` for the honest offset. If durations match, the acceleration
animation, then present for push review. defect is closed; then decide push with the user, and attack any true audio-latency residual as its
own work unit.
+27 -3
View File
@@ -307,9 +307,9 @@ Both halves were solved by OBS/libyuv long ago; do not re-derive:
OBS's answering machinery is the encoder queue (`libobs/obs-encoder.c`): the encoder thread OBS's answering machinery is the encoder queue (`libobs/obs-encoder.c`): the encoder thread
NEVER couples back into the video thread; overflow = dropped data, NEVER a frozen producer. NEVER couples back into the video thread; overflow = dropped data, NEVER a frozen producer.
Fixed as: bounded `Channel<byte[]>` (cap 120) + a dedicated drain task owning stdin, Fixed as: bounded `Channel<byte[]>` (cap 120) + a dedicated drain task owning stdin,
`SubmitFrameAsync` = copy-to-pool-array + `TryWrite` (drop-newest + count when full), pump `SubmitFrameAsync` = copy-to-pool-array + `TryWrite` (drop-newest + count when full), stop
emits ONE fresh composite per iteration (no burst re-write), stop flushes the queue then EOF. flushes the queue then EOF. **Rule: verify with a clock-independent judge.** The WSL ticker that
**Rule: verify with a clock-independent judge.** The WSL ticker that "proved" slice 9 has its "proved" slice 9 has its
own Host-timer jitter under Windows load — so this slice burns a dot-matrix `_outputIndex` own Host-timer jitter under Windows load — so this slice burns a dot-matrix `_outputIndex`
into the bottom-right of every composite; decoding the recording reads the honest sequence into the bottom-right of every composite; decoding the recording reads the honest sequence
(+1/frame, jumps = counted drops) with no external clock involved. Take 17 must read (+1/frame, jumps = counted drops) with no external clock involved. Take 17 must read
@@ -317,6 +317,30 @@ Both halves were solved by OBS/libyuv long ago; do not re-derive:
UNRELIABLE here: the scene is always animating — session elapsed timer + REC pulse — so UNRELIABLE here: the scene is always animating — session elapsed timer + REC pulse — so
no two frames are ever byte-identical.) no two frames are ever byte-identical.)
**SUPERSEDED by slice 15 (2026-09-14):** the last clause "pump emits ONE fresh composite per
iteration (no burst re-write)" had it BACKWARDS for the deadline. Slice 10 chose a skip when
the render overruns — an overrun's slots vanish from the file — and that AUTHORS ACCELERATED
playback (my item-1 lesson already said it: "NEVER a skip … a late render shows as REPEATED
footage"). Device takes proved it: one fresh frame per 35ms stall → `ty-20260914-1726` = 163
video frames (2.72s) against 2.93s of audio, video ending 0.22s early ("audio speeds up then
cuts off"). OBS's answer (docs.obsproject.com/backend-design: "If the video frame queue is
full, it will duplicate the last frame"; `libobs/obs-output.c` counts "lagged frames due to
rendering lag/stalls" — never a time-hole) is to fill each missed slot by DUPLICATING the
newest frame; duration == wall, judder not fast-forward. That's what the pump's submit now
does: a catch-up loop over the missed slots emitting this iteration's composite again, clamped
to a `deadlineNow` captured once (bounded — the smear that justified slice 10 was the
BLOCKING pipe-write re-copying during a long freeze; the queue makes each emit nanoseconds,
so the burst is safe). The burned index moved inside the submit loop: EVERY emitted slot
carries its own +1 (this also fixed the old unconditional `_outputIndex++` that gapped the
sequence on non-submitting fast-render iterations). A/V sync must be re-clap-measured after
this fix — the +0.6s audio-late reading on 1726 was confounded by the 1.1x acceleration.
**Latest A/V sync numbers (2026-09-14, pre-slice-15 pacing):** keep the recipe below honest on
clap measures. ty-1723: 697 video frames (60fps) = 11.62s vs 11.84s audio. ty-1726 (clap): 163
frames = 2.72s vs 2.93s audio; clap audio env peak 1.655s vs video motion cluster 0.75–1.03s →
single-peak offset ≈ +0.64–0.89s, cross-correlation lag +0.667s (audio late) — BOTH confounded by
the acceleration; re-measure after slice 15.
**Take-4 follow-ups (2026-09-04) — the symptom needed a second pass, so cite again:** **Take-4 follow-ups (2026-09-04) — the symptom needed a second pass, so cite again:**
render was still 58.9ms after slice 1. Slice 2 (buffer pool + opaque-row memcpy + render was still 58.9ms after slice 1. Slice 2 (buffer pool + opaque-row memcpy +
+31 -27
View File
@@ -394,34 +394,38 @@ public sealed class FramePump : IDisposable
lock (_gate) encoder = _encoder; lock (_gate) encoder = _encoder;
if (encoder == null) break; if (encoder == null) break;
// Burned-in frame counter (slice 10 judge): a clock-independent // Deadline pacing, OBS duplicate-on-lag (slice 15): emit ONE frame per
// pacing witness burned literally into the composite. Decode the // deadline slot — a fresh composite when the render kept up, a REPEAT
// recording and read the bottom-right strip: the number must advance // of this iteration's latest composite for every slot the render
// +1 per frame and jump only by counted drops (queue overflow or a // overran. The OBS model never leaves a wall-time hole: libobs
// render-overrun's skipped slots). The WSL ticker replaced as the // media-io/video-io.c runs on its own clock and duplicates the latest
// judge because its own timers can smear under Windows host load — // frame when the video thread lags, logging "lagged frames due to
// this can't lie. // rendering lag/stalls" — never skipped time (docs.obsproject.com/
_outputIndex++; // backend-design: "If the video frame queue is full, it will duplicate
BurnFrameIndex(frame.BgraPixels, frame.Width, frame.Height, _outputIndex); // the last frame"). Slice 10 chose the opposite for freshness: an
// overrun SKIPPED the missed slots, so a 60fps-authoring pump emitting
// Deadline pacing (slice 10 reshape): ONE fresh composite per iteration, submitted // one frame per 35ms overrun authored ~1.7x playback (ty-1726: 163
// only when the clock has reached the next deadline. Two changes from // video frames for 2.93s of audio) and ended the video 0.22s before the
// the take-9 counting loop, both derived from where the stale-content // audio tail. Counting duplicates instead of skips keeps recording
// bug actually lived: // duration == wall duration (no acceleration, no audio tail cut) at the
// 1. SubmitFrameAsync no longer blocks on the pipe — it ENQUEUES into // cost of a short judder during a stall — the accepted trade. The burst
// the encoder's bounded queue (the OBS video-thread model), so the // is microseconds (the enqueue never blocks, and the loop is clamped to
// pump can never stall behind ffmpeg, and overflow DROPS the // deadlineNow) so it can't smear the way the take-9 blocking burst did.
// newest frame. The queue wasn't here in the take-9 loop — a var deadlineNow = System.Diagnostics.Stopwatch.GetTimestamp();
// lagging ffmpeg made the pump's submit block, and the burst while (!ct.IsCancellationRequested && deadlineNow >= nextTick)
// while-loop (below) then re-wrote the SAME stale composite for
// every slot that ticked past, which is why the ticker smeared.
// 2. One submission max per iteration: each catch-up slot now gets a
// FRESH render instead of a repeat of the stale one. Missed slots
// vanish from the file (a count-based gap, like the queue drop) —
// never duplicated frozen frames.
if (!ct.IsCancellationRequested
&& System.Diagnostics.Stopwatch.GetTimestamp() >= nextTick)
{ {
// Burned-in frame counter (slice 10 judge): a clock-independent
// pacing witness burned literally into the composite. Decode the
// recording and read the bottom-right strip: the number must
// advance +1 per authored frame and jump only by counted drops
// (queue overflow). Burned here — on the buffer finally submitted,
// which the enqueue's snapshot copies before the next iteration
// rewrites it — so every file frame carries its own index and a
// fast-render iteration that submitted nothing leaves no phantom
// gap (the slice-10 unconditional bump before the gate could).
_outputIndex++;
BurnFrameIndex(frame.BgraPixels, frame.Width, frame.Height, _outputIndex);
submitSw.Restart(); submitSw.Restart();
await encoder.SubmitFrameAsync(frame, ct); await encoder.SubmitFrameAsync(frame, ct);
submitSw.Stop(); submitSw.Stop();
+23
View File
@@ -996,6 +996,29 @@ Full suite 290/291 passing, the sole failure the pre-existing compositor pixel t
- Full suite **293/293 green, 0 warnings**. Audio untouched. **NOT YET VERIFIED ON DEVICE** — the - Full suite **293/293 green, 0 warnings**. Audio untouched. **NOT YET VERIFIED ON DEVICE** — the
composition path needs a real 60fps-widget run (open item; see HANDOFF). Web work commits stay composition path needs a real 60fps-widget run (open item; see HANDOFF). Web work commits stay
LOCAL (no push) until the user greenlights. LOCAL (no push) until the user greenlights.
- **Slice 15 — one frame per deadline SLOT: duplicate-on-lag, never skip (2026-09-14, device takes
ty-1723/1726):** slice 10's freshness choice — a render overrun VANISHES the missed slots from the
file — authored ACCELERATED playback: ~35ms render cost vs the 16.6ms slot, one fresh frame per
35ms (log wall-1726: `FramePump stall: iteration 33ms (> 2× the 17ms interval): worst render
33-46ms`), and a 60fps container muxed on arrival → 1723: 697 frames = 11.62s video vs 11.84s
audio; 1726: 163 frames = 2.72s vs 2.93s. The video also ended 0.21–0.24s before the audio ("audio
speeds up, cuts off at the end"). OBS's answer is duplicate-on-lag: `libobs/media-io/video-io.c`
emits one frame per tick and a lagger DUPLICATES the newest frame, counted as "lagged frames due
to rendering lag/stalls" (obs-output.c) — a wall-time hole never exists (docs.obsproject.com/
backend-design: "If the video frame queue is full, it will duplicate the last frame"). The pump's
submit is now a bounded catch-up: `while (now >= nextTick) { submit; nextTick += intervalTicks; }`
over a `deadlineNow` captured once per iteration — the LATEST composite, fresh on the first missed
slot, repeated (OBS's duplication) for the rest. Duration == wall under any render load, at the
cost of a short judder during a stall. The burst is safe because `Channel.TryWrite`
never blocks (slice 10's queue — the take-9 smear was the BLOCKING pipe-write re-copying a stale buffer
during a long freeze; here each emit is nanoseconds). The burned `_outputIndex` (slice 10 judge)
moved INSIDE the submit loop so every emitted slot carries its own +1 (and the old unconditional
pre-gate bump no longer gaps the sequence on non-submitting fast-render iterations). **Good Dog
test** `Pump_Overrun_Renders_EmitsEverySlot_NotSkipped`: 60fps, resolver sleeps 35ms, asserts ≥0.65
of the wall slots are emitted (the skip-pump wrote ~1/35ms ≈ 200 in 7s; the slot pump ~415). Full
suite **294/294 green, 0 warnings**. The +0.6s audio-late clap reading on 1726 was confounded by
the 1.1x acceleration — re-measure on device; if a real residual remains it is the audio pipeline.
No push (web/A/V work stays local).
- **Stop ordering matters:** `StopAsync` stops the encoder — since slice 10 it FLUSHES the pending - **Stop ordering matters:** `StopAsync` stops the encoder — since slice 10 it FLUSHES the pending
queue (`Channel.TryComplete` → drain writes the leftovers, closes stdin → EOF → ffmpeg finalizes+exits; queue (`Channel.TryComplete` → drain writes the leftovers, closes stdin → EOF → ffmpeg finalizes+exits;
an accepted frame is never lost) — **before** awaiting the loop. The old reverse-order deadlock was an accepted frame is never lost) — **before** awaiting the loop. The old reverse-order deadlock was
+35
View File
@@ -395,6 +395,41 @@ public class FramePumpTests
} }
} }
/// <summary>The ONE integration test for the slice-15 pacing fix (2026-09-14):
/// a render that overruns the deadline must NOT skip the missed slots — a skip
/// authors fast playback: ty-1726 rendered one frame per 35ms overrun into a
/// 60fps container (163 frames for 2.93s of audio) and cut the audio tail off.
/// OBS fills an overrun's slots by DUPLICATING the newest frame (libobs
/// media-io/video-io.c; docs.obsproject.com/backend-design: "If the video frame
/// queue is full, it will duplicate the last frame") so recording duration stays
/// equal to wall duration. Here a 60fps pump renders every frame at ~35ms cost
/// (a genuine overrun): the slice-10 keep-fresh loop authored ~1 per 35ms; the
/// slot-filling loop must emit a frame for ~every 16.6ms of wall time.</summary>
[Fact]
public async Task Pump_Overrun_Renders_EmitsEverySlot_NotSkipped()
{
const int fps = 60;
var encoder = new FakeEncoder();
using var pump = NewPump(encoder, resolve: _ =>
{
System.Threading.Thread.Sleep(35);
return null;
});
await pump.StartAsync();
await Task.Delay(7000); // ~420 slots of wall time
await pump.StopAsync();
// Slot-faithful: ~1 frame per 16.6ms -> ~415 in the window. Skipping (the
// slice-10 behavior): ~1 per 35ms -> ~200. 0.65 * fps * (elapsed seconds)
// is a middle line a skipped pump can never cross, a filling one only fails
// on a pathological run.
Assert.True(encoder.Frames.Count >= (int)(7.0 * fps * 0.65),
$"pump emitted {encoder.Frames.Count} frames in ~7s wall at {fps}fps — "
+ $"{7.0 / encoder.Frames.Count * fps:F1}x playback — an overrun must "
+ "DUPLICATE the newest frame per missed slot (OBS), not skip it");
}
/// <summary>The ONE integration test for the take-4 scratch pool: the pump recycles /// <summary>The ONE integration test for the take-4 scratch pool: the pump recycles
/// its master buffers across frames (the 8.3MB-per-tick LOH churn that cost GC /// its master buffers across frames (the 8.3MB-per-tick LOH churn that cost GC
/// stalls inside "render") while EVERY frame's content stays correct — stale bytes /// stalls inside "render") while EVERY frame's content stays correct — stale bytes