Slice 2. 588 green, 0 warnings. - `FramePumpStats` and `FramePump.StatsReported`: the 5 s window the log line already describes, as data. `Dropped` is the ENCODER's counter (frames submitted, not accepted); `Stalls`/`AvgRenderMs` are the pump's. `FramesRendered` is deliberately not used for classification — a static scene reuses the paste cache and renders few frames while completely healthy, so it would read as starvation. - `StepDownPlanner`: one per broadcast, owns the stateful policy, converts stats → sample, exposes the landed tier for the restart. It never acts — the VM owns the effects, which is what keeps the ladder testable without an encoder. - `Describe` / `ReportLine`: the sentences a creator sees. Machine cause says LlamaCasty couldn't keep up and the connection was not the problem; upload cause blames the upload and promises no further thrash. A test asserts the machine copy never leaks "dropped" — mechanism strings stay in the log. Remaining for the task: VM subscription, the encoder restart at the landed tier, the toast, and shedding our own capture cost before stepping fps.
1001 lines
51 KiB
C#
1001 lines
51 KiB
C#
using System;
|
||
using System.Threading;
|
||
using System.Threading.Tasks;
|
||
using ytLive.Models;
|
||
using ytLive.Services.Compositor;
|
||
|
||
namespace ytLive.Services.Encoder;
|
||
|
||
/// <summary>
|
||
/// The live frame producer (TASK 4 ship step 5): the bridge between the capture
|
||
/// managers + compositor and the encoder. While live it snapshots the active
|
||
/// scene each tick, resolves every element to its latest frame, composites it
|
||
/// into the tier's cropped input frame, and paces frames into the encoder at the tier's
|
||
/// FPS. All collaborators are constructor-injected seams (scene, resolver,
|
||
/// options, encoder factory, pacing delay) so the pump stays free of WPF and of
|
||
/// the capture managers and is fully hermetic in tests.
|
||
///
|
||
/// The RTMP URL comes from the options provider: until the live-stream create
|
||
/// flow lands (TASK 5) it yields null, so go-live runs the existing visual flow
|
||
/// without actually pushing.
|
||
/// </summary>
|
||
public sealed class FramePump : IDisposable
|
||
{
|
||
private readonly Func<Scene?> _sceneProvider;
|
||
private readonly Func<SceneElement, VideoFrame?> _frameResolver;
|
||
private readonly Func<CompositorOptions> _compositorOptions;
|
||
private readonly Func<EncoderOptions?> _encoderOptions;
|
||
private readonly Func<IFfmpegEncoder> _encoderFactory;
|
||
private readonly Action<string>? _log;
|
||
private readonly Func<TimeSpan, CancellationToken, Task> _pacingDelay;
|
||
private readonly Func<(VideoFrame? Frame, SocialBarPosition Position)>? _socialBar;
|
||
private readonly Func<Scene, VideoFrame?>? _alertTicker;
|
||
private readonly Func<VideoFrame?>? _brandFlash;
|
||
private readonly TransitionService? _transition;
|
||
private VideoFrame? _lastOutput;
|
||
private readonly SceneGraph? _sceneGraph;
|
||
private readonly SceneCompositor _compositor = new();
|
||
|
||
/// <summary>Per-stage composite timing for the CURRENT stats window (webcam-cadence
|
||
/// unit, 2026-10-16): the compositor records each stage into this collector and
|
||
/// <see cref="ReportStats"/> logs the averaged breakdown, then clears it — the
|
||
/// "which of the 6 elements eats ~140ms" reading (see the parts line).</summary>
|
||
private readonly CompositeTimings _timings = new();
|
||
|
||
// Master-buffer scratch pool (take-4 starvation fix, slice 2 of 2): a fresh
|
||
// 8.3MB byte[] every tick is ~500MB/s of LOH churn — GC stalls masquerading
|
||
// as render cost. The pump pools ONLY buffers it handed out (reference-equality
|
||
// set), so bake-cache / social-bar / static-cache frames are never touched;
|
||
// SubmitFrameAsync copies the bytes to the encoder's stdin before returning,
|
||
// so recycling after submit is safe (MyMistakes recipe).
|
||
private readonly HashSet<byte[]> _ownedScratch = new(ReferenceEqualityComparer.Instance);
|
||
private readonly List<byte[]> _freeScratch = new();
|
||
private const int MaxScratchPooled = 4;
|
||
|
||
private byte[] AcquireScratch(int size)
|
||
{
|
||
for (var i = _freeScratch.Count - 1; i >= 0; i--)
|
||
{
|
||
if (_freeScratch[i].Length != size) continue;
|
||
var buffer = _freeScratch[i];
|
||
_freeScratch.RemoveAt(i);
|
||
return buffer;
|
||
}
|
||
var fresh = new byte[size];
|
||
_ownedScratch.Add(fresh);
|
||
return fresh;
|
||
}
|
||
|
||
private void ReleaseScratch(byte[]? buffer)
|
||
{
|
||
if (buffer == null || !_ownedScratch.Contains(buffer)) return;
|
||
if (_freeScratch.Count >= MaxScratchPooled || _freeScratch.Contains(buffer)) return;
|
||
_freeScratch.Add(buffer);
|
||
}
|
||
|
||
// Windows sleep quantum (take-9 finding, 2026-09-04): Task.Delay rounds every
|
||
// request up to the system clock tick (~15.6ms default — learn.microsoft.com/en-us/
|
||
// dotnet/api/system.threading.tasks.task.delay: "approximately 15 milliseconds on
|
||
// Windows systems"), so a pacer requesting 3-15ms actually sleeps 15.6ms. Takes
|
||
// 5-9 measured work ~25ms but period ~37ms: one padded wait per frame hid every
|
||
// compositor improvement. Established media-app practice (game-loop/OBS canon —
|
||
// stackoverflow.com/questions/5441464; and raise the resolution for the session —
|
||
// learn.microsoft.com/en-us/windows/win32/api/timeapi/nf-timeapi-timebeginperiod):
|
||
// timeBeginPeriod(1) while the pump runs, sleep only the BULK of the remainder,
|
||
// and spin the last ~2ms across the deadline.
|
||
[System.Runtime.InteropServices.DllImport("winmm.dll")]
|
||
private static extern uint timeBeginPeriod(uint uMilliseconds);
|
||
[System.Runtime.InteropServices.DllImport("winmm.dll")]
|
||
private static extern uint timeEndPeriod(uint uMilliseconds);
|
||
private static readonly long SpinTailTicks = System.Diagnostics.Stopwatch.Frequency * 2 / 1000; // 2ms
|
||
|
||
private readonly object _gate = new();
|
||
private IFfmpegEncoder? _encoder;
|
||
private CancellationTokenSource? _cts;
|
||
private Task? _pumpTask;
|
||
private bool _started;
|
||
private long _outputIndex;
|
||
|
||
/// <summary>Captured by <see cref="ProbeRender"/> when a render exceeds ~20ms —
|
||
/// the 2026-09-12 half-speed-render probe. Appended (once) to the next stats or
|
||
/// stall line, then cleared.</summary>
|
||
private string _renderDetail = "";
|
||
|
||
/// <summary>C4 blit-on-change cache (2026-09-15): the FULL-render path (split=0
|
||
/// when the live-capture backdrop sits at element 0, or no SceneGraph) re-composites
|
||
/// the ENTIRE frame every tick even when no input changed — the ty-1841 take logged
|
||
/// a render stall on EVERY iteration (totalMs 21-44, one composite per ~30ms while
|
||
/// the content moved ~20 updates/s). The SceneGraph split can never help here: the
|
||
/// backdrop is element 0 and must stay dynamic (a baked capture goes stale), so the
|
||
/// cache lives at the pump: the last full-render output plus the input identity that
|
||
/// produced it. Unchanged identity → ONE BlockCopy (~3ms) reuses the composite;
|
||
/// changed identity → re-composite. The cache buffer is a SEPARATE long-lived array,
|
||
/// never the scratch pool — the caller burns the frame counter and recycles the
|
||
/// scratch AFTER RenderScene returns, so the cache is written from the rendered
|
||
/// scratch BEFORE returning (pre-burn, pre-recycle). The identity mirror is
|
||
/// <see cref="BuildFullRenderSignature"/>: it resolves the same frames the compositor
|
||
/// will (same resolver seam), so a changed frame (new capture, new webcam, new chat
|
||
/// raster) forces a re-render and an unchanged one never serves stale bytes. Internal
|
||
/// counters feed the Good Dog test + the 5s cache detail in the stats line.</summary>
|
||
private byte[]? _fullCachePixels;
|
||
private bool _fullCacheValid;
|
||
private ulong _lastFullSignature;
|
||
internal long CacheHits;
|
||
internal long CacheRenders;
|
||
|
||
/// <summary>Forwards the encoder's parsed health — ship step 6 binds this to the bottom bar.</summary>
|
||
public event EventHandler<StreamHealth>? HealthUpdated;
|
||
|
||
/// <summary>Raised when the encoder cannot start or dies mid-stream. The pump stops itself.</summary>
|
||
public event EventHandler<string>? Failed;
|
||
|
||
/// <summary>TASK 32·1: the ENCODER died (the push dropped) — recoverable, unlike a compositor fault.
|
||
/// Raised before the pump stops, so the session can relaunch the push instead of ending.</summary>
|
||
public event EventHandler<string>? EncoderFailed;
|
||
|
||
/// <summary>TASK 33: the same 5-second window the stats line reports, structured — so a policy can
|
||
/// tell machine starvation from encoder backpressure without parsing prose.</summary>
|
||
public event EventHandler<FramePumpStats>? StatsReported;
|
||
|
||
public FramePump(
|
||
Func<Scene?> sceneProvider,
|
||
Func<SceneElement, VideoFrame?> frameResolver,
|
||
Func<CompositorOptions> compositorOptions,
|
||
Func<EncoderOptions?> encoderOptions,
|
||
Func<IFfmpegEncoder> encoderFactory,
|
||
Action<string>? log = null,
|
||
Func<TimeSpan, CancellationToken, Task>? pacingDelay = null,
|
||
Func<(VideoFrame? Frame, SocialBarPosition Position)>? socialBar = null,
|
||
Func<Scene, VideoFrame?>? alertTicker = null,
|
||
Func<VideoFrame?>? brandFlash = null,
|
||
TransitionService? transition = null,
|
||
SceneGraph? sceneGraph = null)
|
||
{
|
||
_sceneProvider = sceneProvider ?? throw new ArgumentNullException(nameof(sceneProvider));
|
||
_frameResolver = frameResolver ?? throw new ArgumentNullException(nameof(frameResolver));
|
||
_compositorOptions = compositorOptions ?? throw new ArgumentNullException(nameof(compositorOptions));
|
||
_encoderOptions = encoderOptions ?? throw new ArgumentNullException(nameof(encoderOptions));
|
||
_encoderFactory = encoderFactory ?? throw new ArgumentNullException(nameof(encoderFactory));
|
||
_log = log;
|
||
_pacingDelay = pacingDelay ?? ((delay, ct) => Task.Delay(delay, ct));
|
||
_socialBar = socialBar;
|
||
_alertTicker = alertTicker;
|
||
_brandFlash = brandFlash;
|
||
_transition = transition;
|
||
_sceneGraph = sceneGraph;
|
||
}
|
||
|
||
public bool IsRunning { get; private set; }
|
||
|
||
/// <summary>The burned counter of the LAST frame submitted to the encoder. Stable
|
||
/// across a tick's multiple resolver passes (it advances only at burn/submit, which
|
||
/// happens AFTER render) — tests key per-tick content on it.</summary>
|
||
internal long OutputIndex => _outputIndex;
|
||
|
||
/// <summary>Never throws: failures are logged and surfaced via <see cref="Failed"/>,
|
||
/// so the VM can fire-and-forget it from a sync command handler.</summary>
|
||
public async Task StartAsync(CancellationToken cancellationToken = default)
|
||
{
|
||
lock (_gate)
|
||
{
|
||
if (_started) return;
|
||
_started = true;
|
||
}
|
||
|
||
IFfmpegEncoder? encoder = null;
|
||
try
|
||
{
|
||
var options = _encoderOptions();
|
||
if (options == null)
|
||
{
|
||
_log?.Invoke("FramePump: no output configured (neither streaming nor recording) — encoder skipped");
|
||
lock (_gate) _started = false;
|
||
return;
|
||
}
|
||
|
||
encoder = _encoderFactory();
|
||
encoder.HealthUpdated += OnHealthUpdated;
|
||
encoder.ProcessFailed += OnProcessFailed;
|
||
await encoder.StartAsync(options, cancellationToken);
|
||
|
||
lock (_gate)
|
||
{
|
||
_encoder = encoder;
|
||
}
|
||
|
||
// IsRunning must be true before the loop starts: the loop reads it on
|
||
// its first iteration, and with a completed-task delay it can run
|
||
// synchronously on this thread before PumpAsync even returns.
|
||
IsRunning = true;
|
||
_cts = new CancellationTokenSource();
|
||
// The loop runs on the thread pool ON PURPOSE (take-8 finding, 2026-09-04):
|
||
// Task.Run installs no SynchronizationContext, so every await continuation
|
||
// stays off the UI dispatcher. Before this, the pump inherited the UI
|
||
// thread's sync context (StartAsync is fired from a command handler), so
|
||
// "render 22ms, wait 10ms" was the producer sitting in the dispatcher
|
||
// queue behind the live preview it is meant to be independent of — the
|
||
// stats quantum fix made the wait VISIBLE; this removes its cause.
|
||
// OBS's video threads are dedicated for exactly this reason.
|
||
_pumpTask = Task.Run(() => PumpAsync(options, _cts.Token));
|
||
var scene = _sceneProvider();
|
||
var comp = _compositorOptions();
|
||
_log?.Invoke($"FramePump started ({options.Width}×{options.Height} @ {options.Fps} fps)" +
|
||
(scene != null ? $" scene='{scene.Name}'" : "") +
|
||
$" comp={comp.SourceRectWidth}x{comp.SourceRectHeight}");
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_log?.Invoke($"FramePump: start failed: {ex.Message}");
|
||
if (encoder != null)
|
||
{
|
||
encoder.HealthUpdated -= OnHealthUpdated;
|
||
encoder.ProcessFailed -= OnProcessFailed;
|
||
try
|
||
{
|
||
encoder.Dispose();
|
||
}
|
||
catch (Exception disposeEx)
|
||
{
|
||
_log?.Invoke($"FramePump: disposing failed encoder: {disposeEx.Message}");
|
||
}
|
||
}
|
||
lock (_gate)
|
||
{
|
||
_started = false;
|
||
IsRunning = false;
|
||
}
|
||
Failed?.Invoke(this, ex.Message);
|
||
}
|
||
}
|
||
|
||
public async Task StopAsync(CancellationToken cancellationToken = default)
|
||
{
|
||
IFfmpegEncoder? encoder;
|
||
Task? pump;
|
||
lock (_gate)
|
||
{
|
||
if (!_started && _encoder == null) return;
|
||
_started = false;
|
||
IsRunning = false;
|
||
encoder = _encoder;
|
||
pump = _pumpTask;
|
||
_cts?.Cancel();
|
||
}
|
||
|
||
// Stop the encoder BEFORE awaiting the pump: closing its stdin unblocks a
|
||
// write stuck on pipe backpressure, otherwise the pump could await forever.
|
||
if (encoder != null)
|
||
{
|
||
try
|
||
{
|
||
await encoder.StopAsync(cancellationToken);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_log?.Invoke($"FramePump: encoder stop failed: {ex.Message}");
|
||
}
|
||
}
|
||
|
||
if (pump != null)
|
||
{
|
||
try
|
||
{
|
||
await pump;
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_log?.Invoke($"FramePump: pump loop faulted during stop: {ex.Message}");
|
||
}
|
||
}
|
||
|
||
if (encoder != null)
|
||
{
|
||
encoder.HealthUpdated -= OnHealthUpdated;
|
||
encoder.ProcessFailed -= OnProcessFailed;
|
||
try
|
||
{
|
||
encoder.Dispose();
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_log?.Invoke($"FramePump: encoder dispose failed: {ex.Message}");
|
||
}
|
||
}
|
||
|
||
lock (_gate)
|
||
{
|
||
_encoder = null;
|
||
_cts = null;
|
||
_pumpTask = null;
|
||
}
|
||
_log?.Invoke("FramePump stopped");
|
||
}
|
||
|
||
public void Dispose()
|
||
{
|
||
try
|
||
{
|
||
StopAsync().GetAwaiter().GetResult();
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_log?.Invoke($"FramePump: dispose failed: {ex.Message}");
|
||
}
|
||
_freeScratch.Clear();
|
||
_ownedScratch.Clear();
|
||
}
|
||
|
||
private async Task PumpAsync(EncoderOptions options, CancellationToken ct)
|
||
{
|
||
var interval = TimeSpan.FromSeconds(1d / Math.Max(1, options.Fps));
|
||
var lastTick = System.Diagnostics.Stopwatch.StartNew();
|
||
|
||
// Deadline pacing (2026-09-03, take-3 fix): the frame interval is a DEADLINE,
|
||
// not an afterthought sleep — the OBS libobs video-io.c pattern (researched
|
||
// before coding; see https://github.com/obsproject/obs-studio/blob/master/
|
||
// libobs/media-io/video-io.c). The old loop slept the FULL interval after
|
||
// each render, so period = render + submit + interval: at take-3's 258ms
|
||
// render that was 3.6fps stamped into a 60fps container — rawvideo stamps by
|
||
// arrival, so 30 wall-seconds muxed as a 2.1s time-lapse, no error anywhere.
|
||
var intervalTicks = Math.Max(1, (long)Math.Round(interval.TotalSeconds * System.Diagnostics.Stopwatch.Frequency));
|
||
var nextTick = System.Diagnostics.Stopwatch.GetTimestamp();
|
||
|
||
// Stage timing (2026-09-01, take two): rawvideo carries no per-frame
|
||
// timestamps — ffmpeg stamps frames by ARRIVAL at the declared fps. A producer
|
||
// slower than the declared rate yields a time-lapsed, short file (observed:
|
||
// 39 frames in 27 wall-seconds ≈ 30x at 60fps) with no error anywhere.
|
||
// Log the render/submit split every 5s so the next take names the stage.
|
||
var renderSw = new System.Diagnostics.Stopwatch();
|
||
var submitSw = new System.Diagnostics.Stopwatch();
|
||
var waitSw = new System.Diagnostics.Stopwatch();
|
||
// Resolve-vs-composite split (2026-09-04, take-6 ambiguity): "render" was a
|
||
// black box — the stats line now reports resolver time separately so a take
|
||
// names the stage (get-frame vs blit) instead of feeding another guess.
|
||
var resolveSw = new System.Diagnostics.Stopwatch();
|
||
long renderTicks = 0, submitTicks = 0, resolveTicks = 0, waitTicks = 0;
|
||
long socialBarTicks = 0, tickerTicks = 0, flashTicks = 0, acquireTicks = 0;
|
||
long worstRender = 0, worstSubmit = 0;
|
||
int statFrames = 0;
|
||
int stalls = 0;
|
||
long lastCacheHits = 0, lastCacheRenders = 0;
|
||
var statsNext = DateTime.UtcNow + TimeSpan.FromSeconds(5);
|
||
// bool _loggedStartDetail = false;
|
||
|
||
// Real-render accounting (UNIT 0, 2026-10-05): every average above was
|
||
// divided by statFrames — the number of frames SUBMITTED, which deadline
|
||
// padding always fills to the full target even when the renderer only
|
||
// produced a fifth of them. A 60fps container therefore reported "300/300
|
||
// frames, avg render 12ms" while the source actually fed it 60 distinct
|
||
// images. Render cost is now divided by the render count, and the render
|
||
// count is reported beside the submit count — the two numbers side by side
|
||
// ARE the duplicate-on-lag ratio, which is the only honest reading of how
|
||
// much of the stream is a repeat.
|
||
int statRenders = 0;
|
||
|
||
// Webcam cadence (UNIT 0, 2026-10-05): the pump sees each element's resolved
|
||
// frame, and VideoFrame.Epoch is the webcam ring's monotonic generation — so a
|
||
// NEW epoch means the webcam actually delivered something new between two of
|
||
// our ticks. This is the counterpart to MediaCaptureFrameSource's arrivals
|
||
// line: it measures what reached the OUTPUT, not what the camera produced. If
|
||
// the camera reports 30/s and this reports 25/s, the loss is between them.
|
||
var webcamCadence = new Helpers.FrameCadence();
|
||
var seenWebcamEpochs = new HashSet<long>();
|
||
|
||
void ReportStats()
|
||
{
|
||
if (DateTime.UtcNow < statsNext) return;
|
||
var target = 5d / interval.TotalSeconds; // frames expected per window
|
||
IFfmpegEncoder? encoder;
|
||
lock (_gate) encoder = _encoder;
|
||
var dropped = encoder == null ? 0 : encoder.DroppedFrames;
|
||
var detail = _renderDetail;
|
||
_renderDetail = "";
|
||
var cacheDetail = "";
|
||
if (statFrames > 0)
|
||
{
|
||
// C4 window detail: rendered composites (R) vs cache reuses (H) since the
|
||
// last report — hard numeric proof the blit-on-change cache engaged.
|
||
var hits = CacheHits - lastCacheHits;
|
||
var renders = CacheRenders - lastCacheRenders;
|
||
lastCacheHits = CacheHits;
|
||
lastCacheRenders = CacheRenders;
|
||
if (hits > 0 || renders > 0) cacheDetail = $", cache {renders}R/{hits}H";
|
||
}
|
||
double freq = System.Diagnostics.Stopwatch.Frequency;
|
||
_log?.Invoke(statFrames == 0
|
||
? "FramePump stats: NO frames produced in 5s (loop stalled?)" + (detail.Length > 0 ? " | " + detail : "")
|
||
: $"FramePump stats: {statFrames}/{target:F0} frames per 5s from {statRenders} renders, " +
|
||
$"avg render {renderTicks / freq * 1000 / Math.Max(1, statRenders):F1}ms " +
|
||
$"(resolve {resolveTicks / freq * 1000 / Math.Max(1, statRenders):F1}, " +
|
||
$"social {socialBarTicks / freq * 1000 / Math.Max(1, statRenders):F2}ms, " +
|
||
$"ticker {tickerTicks / freq * 1000 / Math.Max(1, statRenders):F2}ms, " +
|
||
$"flash {flashTicks / freq * 1000 / Math.Max(1, statRenders):F2}ms, " +
|
||
$"acq {acquireTicks / freq * 1000 / Math.Max(1, statRenders):F2}ms), " +
|
||
$"avg submit {submitTicks / freq * 1000 / statFrames:F1}ms, "
|
||
+ $"avg wait {waitTicks / freq * 1000 / Math.Max(1, statRenders):F1}ms, "
|
||
+ $"worst render {worstRender / freq * 1000:F1}ms, "
|
||
+ $"worst submit {worstSubmit / freq * 1000:F1}ms, "
|
||
+ $"dropped {dropped}, stalls {stalls}{cacheDetail}, {webcamCadence.Summarize().Describe("webcam")}"
|
||
+ (detail.Length > 0 ? " | " + detail : ""));
|
||
|
||
// Per-stage breakdown (webcam-cadence unit, 2026-10-16): the stats line above
|
||
// says WHAT a composite cost; this says WHERE it went — one entry per stage,
|
||
// slowest first, averaged per render so the numbers compare with `avg render`.
|
||
// No entries (fully-static shortcut, or every render a C4 cache hit) means no
|
||
// per-element work happened — that IS the reading. The sub-stage
|
||
// `#i <name> raster` is the paste-cache rasterize miss and nests in its
|
||
// element's total.
|
||
var parts = _timings.Describe(statRenders);
|
||
if (parts.Length > 0)
|
||
_log?.Invoke($"FramePump parts ({statRenders} renders/5s): {parts}");
|
||
_timings.Reset();
|
||
|
||
// Structured twin of the line above, raised before the counters reset. `dropped` is the
|
||
// ENCODER's count (ffmpeg blocked); a renderer that duplicates frames is already visible
|
||
// as render time + stalls, and FramesRendered is ambiguous by design (paste cache).
|
||
StatsReported?.Invoke(this, new FramePumpStats(
|
||
WindowSeconds: 5,
|
||
FramesSubmitted: statFrames,
|
||
FramesRendered: statRenders,
|
||
AvgRenderMs: statRenders > 0 ? renderTicks / freq * 1000 / statRenders : 0,
|
||
AvgSubmitMs: statFrames > 0 ? submitTicks / freq * 1000 / statFrames : 0,
|
||
AvgWaitMs: statRenders > 0 ? waitTicks / freq * 1000 / statRenders : 0,
|
||
WorstRenderMs: worstRender / freq * 1000,
|
||
WorstSubmitMs: worstSubmit / freq * 1000,
|
||
Dropped: dropped,
|
||
Stalls: stalls));
|
||
|
||
worstRender = 0;
|
||
worstSubmit = 0;
|
||
renderTicks = submitTicks = resolveTicks = waitTicks = 0;
|
||
socialBarTicks = tickerTicks = flashTicks = acquireTicks = 0;
|
||
statFrames = 0;
|
||
statRenders = 0;
|
||
stalls = 0;
|
||
webcamCadence.Reset();
|
||
seenWebcamEpochs.Clear();
|
||
statsNext = DateTime.UtcNow + TimeSpan.FromSeconds(5);
|
||
}
|
||
|
||
// One wrapper shared by every render of the run — resolve time accumulates
|
||
// inside the render measurement, and the stats line reports the split.
|
||
timeBeginPeriod(1); // pairs with timeEndPeriod in the finally — see field note
|
||
var previousGcMode = System.Runtime.GCSettings.LatencyMode;
|
||
System.Runtime.GCSettings.LatencyMode = System.Runtime.GCLatencyMode.SustainedLowLatency;
|
||
VideoFrame? TimedResolver(SceneElement element)
|
||
{
|
||
resolveSw.Restart();
|
||
var frame = _frameResolver(element);
|
||
resolveSw.Stop();
|
||
resolveTicks += resolveSw.ElapsedTicks;
|
||
if (element.IsWebcam && frame != null && seenWebcamEpochs.Add(frame.Epoch))
|
||
webcamCadence.Add(System.Diagnostics.Stopwatch.GetTimestamp());
|
||
return frame;
|
||
}
|
||
|
||
Scene? lastDumpedScene = null;
|
||
|
||
try
|
||
{
|
||
while (!ct.IsCancellationRequested)
|
||
{
|
||
var scene = _sceneProvider();
|
||
if (scene != null)
|
||
{
|
||
// Scene dump (webcam-cadence unit, 2026-10-16): the 141ms regression
|
||
// could not be compared take-to-take because the log recorded no scene
|
||
// shape at all. Dump it when the pump starts on a scene and whenever
|
||
// the active scene changes — one header line plus one line per element
|
||
// (z-index, name, kind, source type, visibility, rect, opacity, clip).
|
||
if (!ReferenceEquals(scene, lastDumpedScene))
|
||
{
|
||
lastDumpedScene = scene;
|
||
foreach (var line in FormatSceneDump(scene, _sceneGraph?.GetSplitPoint(scene) ?? -1))
|
||
_log?.Invoke(line);
|
||
}
|
||
|
||
var iterStart = System.Diagnostics.Stopwatch.GetTimestamp();
|
||
var compositorOptions = _compositorOptions();
|
||
VideoFrame? socialBarFrame = null;
|
||
var socialBarTop = 0;
|
||
if (_socialBar != null)
|
||
{
|
||
var (barFrame, position) = _socialBar();
|
||
socialBarFrame = barFrame;
|
||
if (barFrame != null)
|
||
socialBarTop = position == SocialBarPosition.Top
|
||
? 0
|
||
: compositorOptions.SourceRectHeight - barFrame.Height;
|
||
}
|
||
|
||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||
VideoFrame? tickerFrame = _alertTicker?.Invoke(scene);
|
||
tickerTicks += sw.ElapsedTicks;
|
||
sw.Restart();
|
||
VideoFrame? flashFrame = _brandFlash?.Invoke();
|
||
flashTicks += sw.ElapsedTicks;
|
||
sw.Restart();
|
||
var (barFrame2, position2) = (_socialBar == null ? (null, SocialBarPosition.Bottom) : _socialBar());
|
||
socialBarFrame = barFrame2;
|
||
socialBarTop = barFrame2 == null ? 0 : (position2 == SocialBarPosition.Top ? 0 : compositorOptions.SourceRectHeight - barFrame2.Height);
|
||
socialBarTicks += sw.ElapsedTicks;
|
||
|
||
VideoFrame frame;
|
||
if (_transition is { CaptureRequested: true } capture && _lastOutput != null)
|
||
capture.SupplyFromFrame(new VideoFrame(_lastOutput.Width, _lastOutput.Height,
|
||
(byte[])_lastOutput.BgraPixels.Clone()));
|
||
var scratchSize = compositorOptions.SourceRectWidth
|
||
* compositorOptions.SourceRectHeight * 4;
|
||
renderSw.Restart();
|
||
sw.Restart();
|
||
var scratch = AcquireScratch(scratchSize);
|
||
acquireTicks += sw.ElapsedTicks;
|
||
frame = RenderScene(scene, compositorOptions, socialBarFrame, socialBarTop, tickerFrame, flashFrame, scratch, TimedResolver, _timings);
|
||
if (_transition is { Active: true } transition)
|
||
{
|
||
frame = transition.BlendFrame(frame);
|
||
transition.Tick(lastTick.Elapsed.TotalMilliseconds);
|
||
}
|
||
// Restarted EVERY frame (transition or not): a transition begun
|
||
// after idle must not inherit a giant ElapsedMs and complete
|
||
// instantly on its first Tick.
|
||
lastTick.Restart();
|
||
renderSw.Stop();
|
||
renderTicks += renderSw.ElapsedTicks;
|
||
if (renderSw.ElapsedTicks > worstRender) worstRender = renderSw.ElapsedTicks;
|
||
statRenders++;
|
||
|
||
IFfmpegEncoder? encoder;
|
||
lock (_gate) encoder = _encoder;
|
||
if (encoder == null) break;
|
||
|
||
// Deadline pacing, OBS duplicate-on-lag (slice 15): emit ONE frame per
|
||
// deadline slot — a fresh composite when the render kept up, a REPEAT
|
||
// of this iteration's latest composite for every slot the render
|
||
// overran. The OBS model never leaves a wall-time hole: libobs
|
||
// media-io/video-io.c runs on its own clock and duplicates the latest
|
||
// frame when the video thread lags, logging "lagged frames due to
|
||
// rendering lag/stalls" — never skipped time (docs.obsproject.com/
|
||
// backend-design: "If the video frame queue is full, it will duplicate
|
||
// the last frame"). Slice 10 chose the opposite for freshness: an
|
||
// overrun SKIPPED the missed slots, so a 60fps-authoring pump emitting
|
||
// one frame per 35ms overrun authored ~1.7x playback (ty-1726: 163
|
||
// video frames for 2.93s of audio) and ended the video 0.22s before the
|
||
// audio tail. Counting duplicates instead of skips keeps recording
|
||
// duration == wall duration (no acceleration, no audio tail cut) at the
|
||
// cost of a short judder during a stall — the accepted trade. The burst
|
||
// is microseconds (the enqueue never blocks, and the loop is clamped to
|
||
// deadlineNow) so it can't smear the way the take-9 blocking burst did.
|
||
var deadlineNow = System.Diagnostics.Stopwatch.GetTimestamp();
|
||
while (!ct.IsCancellationRequested && deadlineNow >= 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();
|
||
await encoder.SubmitFrameAsync(frame, ct);
|
||
submitSw.Stop();
|
||
statFrames++;
|
||
submitTicks += submitSw.ElapsedTicks;
|
||
if (submitSw.ElapsedTicks > worstSubmit) worstSubmit = submitSw.ElapsedTicks;
|
||
nextTick += intervalTicks;
|
||
}
|
||
|
||
// SubmitFrameAsync copied the bytes into the encoder's queue — the
|
||
// tick's buffers are recyclable once the enqueue snapshot them.
|
||
// The free-list Contains guard keeps the Cut path (BlendFrame
|
||
// returns toFrame itself, aliasing scratch) safe.
|
||
_lastOutput = frame;
|
||
ReleaseScratch(frame.BgraPixels);
|
||
ReleaseScratch(scratch);
|
||
|
||
// Sleep the BULK of the remainder, SPIN the 2ms tail — never hand
|
||
// a sub-tick remainder to the sleep quantum (the timeBeginPeriod
|
||
// note). If the renderer already ate the budget there is nothing
|
||
// left to sleep and the loop renders the next frame straight away.
|
||
waitSw.Restart();
|
||
var ahead = nextTick - System.Diagnostics.Stopwatch.GetTimestamp();
|
||
if (ahead > SpinTailTicks)
|
||
await _pacingDelay(TimeSpan.FromSeconds(
|
||
(ahead - SpinTailTicks) / (double)System.Diagnostics.Stopwatch.Frequency), ct);
|
||
while (!ct.IsCancellationRequested
|
||
&& System.Diagnostics.Stopwatch.GetTimestamp() < nextTick)
|
||
Thread.SpinWait(400);
|
||
waitSw.Stop();
|
||
waitTicks += waitSw.ElapsedTicks;
|
||
|
||
// Stall logger (slice 10): an iteration spanning more than two full
|
||
// intervals is the old bug's fingerprint — name the stage instead of
|
||
// guessing. With the queue, submit should be ~1ms, so a stall here
|
||
// means RENDER or RESOLVE (the stage terms of the last take).
|
||
var iterWall = System.Diagnostics.Stopwatch.GetTimestamp() - iterStart;
|
||
if (iterWall > 2 * intervalTicks)
|
||
{
|
||
stalls++;
|
||
var detail = _renderDetail;
|
||
_renderDetail = "";
|
||
_log?.Invoke($"FramePump stall: iteration {iterWall / (double)System.Diagnostics.Stopwatch.Frequency * 1000:F0}ms " +
|
||
$"(> 2× the {interval.TotalMilliseconds:F0}ms interval): worst render " +
|
||
$"{worstRender / (double)System.Diagnostics.Stopwatch.Frequency * 1000:F0}ms, worst submit " +
|
||
$"{worstSubmit / (double)System.Diagnostics.Stopwatch.Frequency * 1000:F0}ms, dropped {encoder.DroppedFrames}" +
|
||
(detail.Length > 0 ? " | " + detail : ""));
|
||
}
|
||
ReportStats();
|
||
}
|
||
}
|
||
}
|
||
catch (OperationCanceledException)
|
||
{
|
||
// normal stop
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
// A failure while the pump is supposed to run (encoder died under us,
|
||
// scene provider faulted, ...) stops the pump and surfaces once.
|
||
if (ct.IsCancellationRequested)
|
||
{
|
||
_log?.Invoke($"FramePump: pump exited during stop: {ex.Message}");
|
||
}
|
||
else
|
||
{
|
||
_log?.Invoke($"FramePump: pump loop faulted: {ex.Message}");
|
||
Failed?.Invoke(this, ex.Message);
|
||
}
|
||
}
|
||
finally
|
||
{
|
||
System.Runtime.GCSettings.LatencyMode = previousGcMode;
|
||
timeEndPeriod(1);
|
||
lock (_gate) IsRunning = false;
|
||
}
|
||
}
|
||
|
||
private void OnHealthUpdated(object? sender, StreamHealth health) => HealthUpdated?.Invoke(this, health);
|
||
|
||
/// <summary>Render a scene, using the baked-crust optimization when a <see cref="SceneGraph"/>
|
||
/// is wired in: bake/cache the static layers below the split point, then composite the
|
||
/// dynamic/above-split layers per frame. Without a SceneGraph, falls back to a full render
|
||
/// (identical output — see SceneCompositorTests). <paramref name="scratch"/> is a pooled
|
||
/// master buffer when supplied; the fully-static path returns the bake-cache frame itself
|
||
/// (never pooled — the release side checks owned-by-reference).</summary>
|
||
private VideoFrame RenderScene(
|
||
Scene scene,
|
||
CompositorOptions options,
|
||
VideoFrame? socialBarFrame,
|
||
int socialBarTop,
|
||
VideoFrame? tickerFrame,
|
||
VideoFrame? flashFrame,
|
||
byte[]? scratch = null,
|
||
Func<SceneElement, VideoFrame?>? resolver = null,
|
||
CompositeTimings? timings = null)
|
||
{
|
||
resolver ??= _frameResolver;
|
||
if (_sceneGraph == null)
|
||
{
|
||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||
var frame = RenderFull(scene, options, socialBarFrame, socialBarTop, tickerFrame, flashFrame, scratch, resolver, timings);
|
||
ProbeRender("full-no-graph", scene, -1, sw.ElapsedMilliseconds, 0);
|
||
return frame;
|
||
}
|
||
|
||
var split = _sceneGraph.GetSplitPoint(scene);
|
||
if (split == scene.Elements.Count)
|
||
{
|
||
// Fully static scene: bake once, reuse.
|
||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||
var baked = _sceneGraph.GetBakedBase(scene, _frameResolver, _compositorOptions, timings: timings);
|
||
if (baked != null)
|
||
{
|
||
// BUG (pre-existing, found while wiring the branding credit 2026-09-26):
|
||
// this path returned the stretched bake and silently DROPPED the social
|
||
// bar and the alert ticker. Overlay() copies before blitting, so the
|
||
// cached bake is not mutated and the flash still cannot get sticky in it.
|
||
if (socialBarFrame != null || tickerFrame != null || flashFrame != null)
|
||
{
|
||
var tOverlay = System.Diagnostics.Stopwatch.GetTimestamp();
|
||
var withOverlays = baked;
|
||
if (flashFrame != null) withOverlays = SceneCompositor.Overlay(withOverlays, options, flashFrame, 0, 0);
|
||
if (tickerFrame != null) withOverlays = SceneCompositor.Overlay(withOverlays, options, tickerFrame, tickerFrame.OriginX, tickerFrame.OriginY);
|
||
if (socialBarFrame != null) withOverlays = SceneCompositor.Overlay(withOverlays, options, socialBarFrame, socialBarTop, 0);
|
||
baked = withOverlays;
|
||
timings?.Add("overlay", System.Diagnostics.Stopwatch.GetTimestamp() - tOverlay);
|
||
}
|
||
var tScale = System.Diagnostics.Stopwatch.GetTimestamp();
|
||
var stretched = StretchMath.BilinearScale(baked, options.OutputWidth, options.OutputHeight);
|
||
timings?.Add("scale", System.Diagnostics.Stopwatch.GetTimestamp() - tScale);
|
||
ProbeRender("fully-static", scene, split, sw.ElapsedMilliseconds, (int)sw.ElapsedMilliseconds);
|
||
return stretched;
|
||
}
|
||
}
|
||
|
||
var swBase = System.Diagnostics.Stopwatch.StartNew();
|
||
var baseFrame = _sceneGraph.GetBakedBase(scene, _frameResolver, _compositorOptions, timings: timings);
|
||
if (baseFrame != null)
|
||
{
|
||
var frame = SceneCompositor.CompositeLayers(
|
||
baseFrame, scene, split, resolver, options, socialBarFrame, socialBarTop, flashFrame: flashFrame, tickerFrame: tickerFrame, scratch: scratch, timings: timings);
|
||
ProbeRender("base+layers", scene, split, swBase.ElapsedMilliseconds, 0);
|
||
return frame;
|
||
}
|
||
|
||
// No static base (first layer is dynamic or empty scene) — full render.
|
||
var swFull = System.Diagnostics.Stopwatch.StartNew();
|
||
var frame2 = RenderFull(scene, options, socialBarFrame, socialBarTop, tickerFrame, flashFrame, scratch, resolver, timings);
|
||
ProbeRender("full-render", scene, split, swFull.ElapsedMilliseconds, 0);
|
||
return frame2;
|
||
}
|
||
|
||
/// <summary>The full-render path (no SceneGraph, or no static base) wrapped in the
|
||
/// C4 blit-on-change cache: when every resolved input plus every element's
|
||
/// layout/visual bits match the last composite, reuse it with one BlockCopy instead
|
||
/// of re-compositing. Mirror the compositor's resolution <i>before</i> deciding so a
|
||
/// hit costs ~3ms of pure copy and a miss costs exactly the old full render.</summary>
|
||
private VideoFrame RenderFull(
|
||
Scene scene, CompositorOptions options, VideoFrame? socialBarFrame, int socialBarTop,
|
||
VideoFrame? tickerFrame, VideoFrame? flashFrame,
|
||
byte[]? scratch, Func<SceneElement, VideoFrame?> resolver,
|
||
CompositeTimings? timings = null)
|
||
{
|
||
// The cache stores the compositor frame at crop size; FFmpeg scales that frame
|
||
// to the final output dimensions. With product options, scratch and rendered
|
||
// frames are therefore the same size, including for vertical output.
|
||
var useCache = scratch != null && options.SourceRectWidth == options.OutputWidth
|
||
&& options.SourceRectHeight == options.OutputHeight;
|
||
var signature = useCache
|
||
? BuildFullRenderSignature(scene, options, socialBarFrame, socialBarTop, tickerFrame, flashFrame, resolver)
|
||
: 0UL;
|
||
|
||
if (useCache && _fullCacheValid && signature == _lastFullSignature
|
||
&& _fullCachePixels!.Length == scratch!.Length)
|
||
{
|
||
Buffer.BlockCopy(_fullCachePixels, 0, scratch, 0, scratch.Length);
|
||
CacheHits++;
|
||
return new VideoFrame(options.SourceRectWidth, options.SourceRectHeight, scratch);
|
||
}
|
||
|
||
var rendered = _compositor.Render(scene, resolver, flashFrame, options, socialBarFrame, socialBarTop, scratch: scratch, tickerFrame: tickerFrame, timings: timings);
|
||
CacheRenders++;
|
||
if (useCache)
|
||
{
|
||
var pixels = rendered.BgraPixels;
|
||
if (_fullCachePixels == null || _fullCachePixels.Length != pixels.Length)
|
||
_fullCachePixels = new byte[pixels.Length];
|
||
// PRE-burn / PRE-recycle: the caller burns the counter and recycles this
|
||
// scratch after RenderScene returns — the cache keeps a clean copy, and the
|
||
// burn on the next hit writes to a FRESH scratch copy, never the cache.
|
||
Buffer.BlockCopy(pixels, 0, _fullCachePixels, 0, pixels.Length);
|
||
_lastFullSignature = signature;
|
||
_fullCacheValid = true;
|
||
}
|
||
else
|
||
{
|
||
_fullCacheValid = false; // size changed — never reuse a wrong-size buffer
|
||
}
|
||
return rendered;
|
||
}
|
||
|
||
/// <summary>The pump's scene dump (webcam-cadence unit, 2026-10-16): one header line
|
||
/// plus one line per element, in z-order, carrying the shape two takes must be
|
||
/// compared by — index, name, kind, source type, visibility, rect in master pixels,
|
||
/// opacity, clip. <paramref name="split"/> is the SceneGraph split point (-1 when no
|
||
/// graph is wired: the full-render path). The 10:03 vs 12:01 regression
|
||
/// (29–34ms → 143–155ms at "the same scene shape") could not be compared because the
|
||
/// log recorded no scene at all; this is that record.</summary>
|
||
internal static string[] FormatSceneDump(Scene scene, int split)
|
||
{
|
||
var lines = new List<string>
|
||
{
|
||
$"FramePump scene dump: '{scene.Name}' elements={scene.Elements.Count}"
|
||
+ (split >= 0 ? $" split={split}" : "")
|
||
};
|
||
for (var i = 0; i < scene.Elements.Count; i++)
|
||
{
|
||
var e = scene.Elements[i];
|
||
var source = e is Source s
|
||
? $" type={s.Type}{(s.IsBackground ? " background" : "")}"
|
||
: "";
|
||
lines.Add($" [{i}] {e.Name} kind={e.Kind}{source}"
|
||
+ $" vis={(e.IsVisible ? 1 : 0)} rect={e.X:F0},{e.Y:F0} {e.Width:F0}x{e.Height:F0}"
|
||
+ $" op={e.Opacity:F2} clip={e.ClipShape}");
|
||
}
|
||
return lines.ToArray();
|
||
}
|
||
|
||
/// <summary>Hash the complete set of inputs the compositor's FULL render consumes:
|
||
/// the crop/output dimensions, the social bar, and — in element order, mirroring
|
||
/// <c>SceneCompositor.Render</c>'s iteration — every element's layout/visual fields
|
||
/// plus the frame each one resolves (through the SAME resolver seam the render will
|
||
/// use). A changed frame (new capture, new webcam Epoch, new chat raster) changes the
|
||
/// hash → re-composite; an unchanged one reuses the cache. Buffer-array identity +
|
||
/// Epoch + CropBounds is the compositor's own paste-key shape — producers hand out
|
||
/// fresh immutable arrays, or recycled-ring arrays whose monotonic Epoch
|
||
/// distinguishes generations (the capture take-11 fix), so address-identity alone is
|
||
/// never trusted to key recycled content.</summary>
|
||
private static ulong BuildFullRenderSignature(
|
||
Scene scene, CompositorOptions options, VideoFrame? socialBarFrame, int socialBarTop,
|
||
VideoFrame? tickerFrame,
|
||
VideoFrame? flashFrame,
|
||
Func<SceneElement, VideoFrame?> resolver)
|
||
{
|
||
var h = 14695981039346656037UL; // FNV-1a offset basis
|
||
h = Mix(h, (ulong)options.SourceRectX);
|
||
h = Mix(h, (ulong)options.SourceRectY);
|
||
h = Mix(h, (ulong)options.SourceRectWidth);
|
||
h = Mix(h, (ulong)options.SourceRectHeight);
|
||
h = Mix(h, (ulong)options.OutputWidth);
|
||
h = Mix(h, (ulong)options.OutputHeight);
|
||
h = Mix(h, (ulong)socialBarTop);
|
||
if (socialBarFrame != null) h = MixFrame(h, socialBarFrame);
|
||
else h = Mix(h, 0xFFFFFFFFFFFFFFFFUL);
|
||
if (tickerFrame != null) h = MixFrame(h, tickerFrame);
|
||
else h = Mix(h, 0xFFFFFFFFFFFFFFFFUL);
|
||
// BrandFlashPresenter recycles one buffer and bumps Epoch every read, so this
|
||
// invalidates the cache on every tick of a credit — a cached frame would freeze
|
||
// the fade and pin a stale spot.
|
||
if (flashFrame != null) h = MixFrame(h, flashFrame);
|
||
else h = Mix(h, 0xFFFFFFFFFFFFFFFFUL);
|
||
|
||
var backdropResolved = false;
|
||
foreach (var element in scene.Elements)
|
||
{
|
||
h = Mix(h, (ulong)System.Runtime.CompilerServices.RuntimeHelpers.GetHashCode(element));
|
||
h = Mix(h, element.IsVisible ? 1UL : 0UL);
|
||
h = MixDouble(h, element.X);
|
||
h = MixDouble(h, element.Y);
|
||
h = MixDouble(h, element.Width);
|
||
h = MixDouble(h, element.Height);
|
||
h = MixDouble(h, element.Opacity);
|
||
h = Mix(h, (ulong)element.ClipShape);
|
||
h = Mix(h, element.IsMirrored ? 1UL : 0UL);
|
||
h = Mix(h, (ulong)element.BorderWidth);
|
||
h = MixDouble(h, element.BorderOpacity);
|
||
if (element.TryGetBorderColor(out var bcR, out var bcG, out var bcB))
|
||
h = Mix(h, (ulong)((bcR << 16) | (bcG << 8) | bcB));
|
||
else
|
||
h = Mix(h, 0UL);
|
||
|
||
// Mirror the compositor's per-element frame resolution EXACTLY, so the hash
|
||
// reacts to the same frames a render uses and never to a frame a render
|
||
// ignores (a false-positive change only wastes one render; a false NEGATIVE
|
||
// would serve stale bytes — that is what this mirror prevents).
|
||
VideoFrame? frame = null;
|
||
if (element is Source { IsBackground: true })
|
||
{
|
||
h = Mix(h, 1UL); // the live backdrop — first visible one is resolved, once
|
||
if (element.IsVisible && !backdropResolved)
|
||
{
|
||
frame = resolver(element);
|
||
backdropResolved = true;
|
||
}
|
||
}
|
||
else if (element is Source { Type: SourceType.TextOverlay })
|
||
{
|
||
h = Mix(h, 2UL); // static text is resolved from its raster-on-change frame cache
|
||
if (element.IsVisible) frame = resolver(element);
|
||
}
|
||
else if (element is Source { Type: SourceType.Background })
|
||
{
|
||
h = Mix(h, 3UL); // background image — resolved even when invisible (Render does)
|
||
frame = resolver(element);
|
||
}
|
||
else
|
||
{
|
||
h = Mix(h, 4UL); // regular layer — resolved only when visible
|
||
if (element.IsVisible) frame = resolver(element);
|
||
}
|
||
if (frame != null) h = MixFrame(h, frame);
|
||
else h = Mix(h, 0UL);
|
||
}
|
||
return h;
|
||
}
|
||
|
||
private static ulong Mix(ulong h, ulong v) => (h ^ v) * 0x9E3779B97F4A7C15UL;
|
||
|
||
private static ulong MixDouble(ulong h, double v)
|
||
=> Mix(h, unchecked((ulong)BitConverter.DoubleToInt64Bits(v)));
|
||
|
||
private static ulong MixFrame(ulong h, VideoFrame frame)
|
||
{
|
||
h = Mix(h, (ulong)System.Runtime.CompilerServices.RuntimeHelpers.GetHashCode(frame.BgraPixels));
|
||
h = Mix(h, (ulong)frame.Epoch);
|
||
h = Mix(h, (ulong)frame.Width);
|
||
h = Mix(h, (ulong)frame.Height);
|
||
if (frame.CropBounds is { } cb)
|
||
{
|
||
h = Mix(h, (ulong)cb.X);
|
||
h = Mix(h, (ulong)cb.Y);
|
||
h = Mix(h, (ulong)cb.W);
|
||
h = Mix(h, (ulong)cb.H);
|
||
}
|
||
else
|
||
{
|
||
h = Mix(h, 0xFFFFFFFFFFFFFFFFUL);
|
||
}
|
||
return h;
|
||
}
|
||
|
||
/// <summary>2026-09-12 half-speed-render probe: the pump only produces ~27fps
|
||
/// (render ~35ms vs the 16.7ms deadline), and rawvideo muxes at the DECLARED fps,
|
||
/// so every take muxes at ~half its wall length (the truncation complaint). This
|
||
/// names WHICH compositor path ate the slow frame so the fix targets the real
|
||
/// stage. Records only when a frame takes ≥20ms (or carries the previous detail).</summary>
|
||
private void ProbeRender(string path, Scene scene, int split, long totalMs, int bakeMs)
|
||
{
|
||
if (totalMs < 20 && _renderDetail.Length == 0) return;
|
||
var dynamics = 0;
|
||
foreach (var e in scene.Elements)
|
||
if (e.Kind == ElementKind.Dynamic) dynamics++;
|
||
_renderDetail = $"render={path} split={split} elements={scene.Elements.Count} dynamic={dynamics}" +
|
||
(bakeMs > 0 ? $" bakeMs={bakeMs}" : "") + $" totalMs={totalMs}";
|
||
}
|
||
|
||
// Dot-matrix digits (5×7, one row per raster line, '1' = lit) burned into the
|
||
// bottom-right of every composite. Basic OCR-safe shapes, sized so the strip is
|
||
// a 36×8 white box in the corner of a 1920×1080 frame — readable with a zoomed
|
||
// player, invisible at normal size.
|
||
private static readonly string[][] DigitGlyphs =
|
||
{
|
||
new[] { "01110","10001","10001","10001","10001","10001","01110" }, // 0
|
||
new[] { "00100","01100","00100","00100","00100","00100","01110" }, // 1
|
||
new[] { "01110","10001","00001","00010","00100","01000","11111" }, // 2
|
||
new[] { "11111","00001","00010","00110","00001","10001","01110" }, // 3
|
||
new[] { "00010","00110","01010","10010","11111","00010","00010" }, // 4
|
||
new[] { "11111","10000","11110","00001","00001","10001","01110" }, // 5
|
||
new[] { "01110","10001","10000","11110","10001","10001","01110" }, // 6
|
||
new[] { "11111","00001","00010","00100","01000","01000","01000" }, // 7
|
||
new[] { "01110","10001","10001","01110","10001","10001","01110" }, // 8
|
||
new[] { "01110","10001","10001","01111","00001","10001","01110" }, // 9
|
||
};
|
||
|
||
private static void BurnFrameIndex(byte[] bgra, int width, int height, long index)
|
||
{
|
||
const int digitW = 5, digitH = 7, gap = 1, margin = 2, digitCount = 6;
|
||
var stripW = digitCount * (digitW + gap) - gap;
|
||
var left = width - margin - stripW;
|
||
var top = height - margin - digitH;
|
||
|
||
// Solid white box under the digits — the underlying scene can be anything.
|
||
for (var y = top; y < top + digitH; y++)
|
||
for (var x = left; x < left + stripW; x++)
|
||
{
|
||
var i = (y * width + x) * 4;
|
||
bgra[i] = 255;
|
||
bgra[i + 1] = 255;
|
||
bgra[i + 2] = 255;
|
||
}
|
||
|
||
var text = index.ToString("D6");
|
||
for (var d = 0; d < digitCount; d++)
|
||
{
|
||
var glyph = DigitGlyphs[text[d] - '0'];
|
||
for (var row = 0; row < digitH; row++)
|
||
for (var col = 0; col < digitW; col++)
|
||
{
|
||
if (glyph[row][col] != '1') continue;
|
||
var i = ((top + row) * width + (left + d * (digitW + gap) + col)) * 4;
|
||
bgra[i] = 0;
|
||
bgra[i + 1] = 0;
|
||
bgra[i + 2] = 0;
|
||
}
|
||
}
|
||
}
|
||
|
||
private void OnProcessFailed(object? sender, string message)
|
||
{
|
||
_log?.Invoke($"FramePump: encoder process failed: {message}");
|
||
// A dead push is not a dead session: the broadcast may still be live, so this is the
|
||
// recoverable signal (TASK 32·1). Fatal pump faults still go through Failed.
|
||
EncoderFailed?.Invoke(this, message);
|
||
_ = StopAsync();
|
||
}
|
||
}
|