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:
+60
-65
@@ -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
@@ -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 +
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user