94057c3d3e
PR Test (NPU) / check-changes (push) Has been cancelled
PR Test (NPU) / pr-gate (push) Has been cancelled
PR Test (NPU) / set-image-config (push) Has been cancelled
PR Test (NPU) / stage-b-test-1-npu-a2 (0) (push) Has been cancelled
PR Test (NPU) / stage-b-test-1-npu-a2 (1) (push) Has been cancelled
PR Test (NPU) / stage-b-test-2-npu-a2 (0) (push) Has been cancelled
PR Test (NPU) / stage-b-test-2-npu-a2 (1) (push) Has been cancelled
PR Test (NPU) / stage-b-test-4-npu-a3 (push) Has been cancelled
PR Test (NPU) / stage-b-test-16-npu-a3 (push) Has been cancelled
PR Test (NPU) / multimodal-gen-test-1-npu-a3 (push) Has been cancelled
PR Test (NPU) / multimodal-gen-test-2-npu-a3 (push) Has been cancelled
PR Test (Arm64) / pr-gate (push) Has been cancelled
PR Test (Arm64) / check-changes (push) Has been cancelled
PR Test (Arm64) / build-test (push) Has been cancelled
PR Test (sgl-router) / gate (push) Has been cancelled
PR Test (sgl-router) / tier-1 — lint (push) Has been cancelled
PR Test (sgl-router) / tier-2 — build + test (push) Has been cancelled
PR Test (sgl-router) / tier-3 — docker (placeholder) (push) Has been cancelled
PR Test (sgl-router) / tier-3 — k8s integration (push) Has been cancelled
PR Test (sgl-router) / tier-3 — e2e (push) Has been cancelled
PR Test (sgl-router) / finish (push) Has been cancelled
PR Test (NPU) / single-node-poc (map[name:qwen3_6_27b_w8a8_1p_in64k_out1k_50ms runner:linux-aarch64-a3-2 test_case:test/registered/ascend/performance/qwen3_6_27b/test_npu_qwen3_6_27b_w8a8_1p_in64k_out1k_50ms.py test_type:perf]) (push) Has been cancelled
PR Test (NPU) / pr-test-npu-finish (push) Has been cancelled
PR Test (Xeon) / pr-gate (push) Has been cancelled
PR Test (Xeon) / check-changes (push) Has been cancelled
PR Test (Xeon) / build-test (, xeon-gnr, base-b-test-cpu) (push) Has been cancelled
PR Test (XPU) / check-changes (push) Has been cancelled
PR Test (XPU) / pr-gate (push) Has been cancelled
PR Test (XPU) / stage-a-test-1-gpu-xpu (push) Has been cancelled
PR Test (XPU) / wait-for-stage-a (push) Has been cancelled
PR Test (XPU) / stage-b-test-1-gpu-xpu (push) Has been cancelled
PR Test (XPU) / finish (push) Has been cancelled
CI Model Inventory / build-inventory (push) Has been cancelled
Lint / lint (push) Has been cancelled
PR Benchmark (SMG Components) / Benchmark Compilation Check (push) Has been cancelled
PR Benchmark (SMG Components) / Benchmark - Manual Policy (push) Has been cancelled
PR Benchmark (SMG Components) / Benchmark - Request Processing (push) Has been cancelled
PR Benchmark (SMG Components) / Benchmark Summary (push) Has been cancelled
PR Test (SMG) / build-wheel (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on windows (x86_64 - auto) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on macos (x86_64 - auto) (push) Has been cancelled
PR Test (SMG) / python-unit-tests (push) Has been cancelled
PR Test (SMG) / unit-tests (push) Has been cancelled
PR Test (SMG) / benchmarks (push) Has been cancelled
PR Test (SMG) / chat-completions (push) Has been cancelled
PR Test (SMG) / chat-completions-4gpu (push) Has been cancelled
PR Test (SMG) / e2e (push) Has been cancelled
PR Test (SMG) / docker-build-test (push) Has been cancelled
PR Test (SMG) / k8s-integration (push) Has been cancelled
PR Test (SMG) / finish (push) Has been cancelled
PR Test (SMG) / summarize-benchmarks (push) Has been cancelled
Release SGLang Model Gateway Docker Image / publish (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on macos (aarch64 - auto) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on linux (aarch64 - auto) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on linux (x86_64 - auto) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on linux (aarch64 - musllinux_1_1) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on linux (x86_64 - musllinux_1_1) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / Build SDist (push) Has been cancelled
Release SGLang Model Gateway to PyPI / Upload to PyPI (push) Has been cancelled
Release SGLang Kernels / build-cu129-matrix (aarch64, 12.9, 3.10, arm-kernel-build-node) (push) Has been cancelled
Release SGLang Kernels / build-cu129-matrix (x86_64, 12.9, 3.10, x64-kernel-build-node) (push) Has been cancelled
Release SGLang Kernels / release-cu129 (push) Has been cancelled
Release SGLang Kernels / build-cu130-matrix (aarch64, 13.0, 3.10, arm-kernel-build-node) (push) Has been cancelled
Release SGLang Kernels / build-cu130-matrix (x86_64, 13.0, 3.10, x64-kernel-build-node) (push) Has been cancelled
Release SGLang Kernels / release-cu130 (push) Has been cancelled
Release SGLang Kernels / build-rocm-matrix (3.10, 700) (push) Has been cancelled
Release SGLang Kernels / build-rocm-matrix (3.10, 720) (push) Has been cancelled
Release SGLang Kernels / release-rocm700 (push) Has been cancelled
Release SGLang Kernels / release-rocm720 (push) Has been cancelled
Release SGLang Kernels / build-musa43 (43, 3.10) (push) Has been cancelled
Release SGLang Kernels / release-musa43 (push) Has been cancelled
518 lines
18 KiB
JavaScript
518 lines
18 KiB
JavaScript
(function attachRealtimePlaybackController(global) {
|
|
const DEFAULT_CONFIG = {
|
|
targetFps: 25,
|
|
minSourceFps: 1,
|
|
serverFpsAlphaUp: 0.28,
|
|
serverFpsAlphaDown: 0.2,
|
|
deliveryFpsAlphaUp: 0.08,
|
|
deliveryFpsAlphaDown: 0.55,
|
|
targetLeadChunkRatio: 1.5,
|
|
minTargetLeadMs: 1500,
|
|
maxTargetLeadMs: 2600,
|
|
maxLeadExtraChunkRatio: 8.0,
|
|
startLeadChunkRatio: 1.85,
|
|
minStartLeadMs: 1700,
|
|
resumeLeadChunkRatio: 2.5,
|
|
minResumeLeadMs: 1000,
|
|
maxResumeLeadMs: 1800,
|
|
rebufferLeadBoostMs: 250,
|
|
rebufferLeadBoostDecayMsPerSecond: 120,
|
|
deliveryLeadBoostDecayMsPerSecond: 80,
|
|
maxDeliveryLeadBoostMs: 2000,
|
|
deliveryStallExpectedMultiplier: 1.25,
|
|
receiveStallPlaybackRateMin: 0.65,
|
|
receiveStallPlaybackRateSlewPerSecond: 0.5,
|
|
lowWaterRatio: 0.4,
|
|
playbackRateGain: 0.14,
|
|
playbackRateMin: 0.92,
|
|
playbackRateMax: 1.08,
|
|
emergencyPlaybackRateMin: 0.9,
|
|
emergencyPlaybackRateMax: 1.12,
|
|
playbackRateSlewPerSecond: 0.08,
|
|
eventCutoverMaxMs: 420,
|
|
eventCutoverMaxFrames: 10,
|
|
settleEventCutoverMaxMs: 720,
|
|
settleEventCutoverMaxFrames: 18,
|
|
startupWarmupMinMs: 1500,
|
|
startupWarmupExpectedMultiplier: 3,
|
|
};
|
|
|
|
function clamp(value, min, max) {
|
|
return Math.min(max, Math.max(min, value));
|
|
}
|
|
|
|
function finitePositive(value) {
|
|
return Number.isFinite(value) && value > 0;
|
|
}
|
|
|
|
class RealtimePlaybackController {
|
|
constructor(config = {}) {
|
|
this.config = { ...DEFAULT_CONFIG, ...config };
|
|
this.reset({ targetFps: this.config.targetFps });
|
|
}
|
|
|
|
reset({ targetFps } = {}) {
|
|
this.targetFps = Math.max(1, Number(targetFps || this.config.targetFps));
|
|
this.sourceFps = this.targetFps;
|
|
this.serverFps = this.targetFps;
|
|
this.deliveryFps = this.targetFps;
|
|
this.hasServerSample = false;
|
|
this.hasDeliverySample = false;
|
|
this.latestChunkDurationMs = 1000 / this.targetFps;
|
|
this.latestChunkFrames = 1;
|
|
this.playbackRate = 1;
|
|
this.renderFps = this.targetFps;
|
|
this.queue = [];
|
|
this.lastDrawAt = 0;
|
|
this.lastRateUpdateAt = 0;
|
|
this.renderedFrames = 0;
|
|
this.droppedFrames = 0;
|
|
this.buffering = true;
|
|
this.pendingEventId = 0;
|
|
this.pendingEventSentAt = 0;
|
|
this.pendingEventCutoverMode = "motion";
|
|
this.lastDropReason = "";
|
|
this.lastDropAt = 0;
|
|
this.lastDropCount = 0;
|
|
this.rebufferLeadBoostMs = 0;
|
|
this.deliveryLeadBoostMs = 0;
|
|
this.chunkReceives = new Map();
|
|
this.serverStatChunks = new Set();
|
|
this.lastFinalReceiveAt = 0;
|
|
this.receiveStalled = false;
|
|
}
|
|
|
|
setTargetFps(targetFps) {
|
|
const nextTargetFps = Math.max(1, Number(targetFps || this.config.targetFps));
|
|
this.targetFps = nextTargetFps;
|
|
if (!this.hasServerSample && !this.hasDeliverySample) {
|
|
this.serverFps = nextTargetFps;
|
|
this.deliveryFps = nextTargetFps;
|
|
this.sourceFps = nextTargetFps;
|
|
this.renderFps = nextTargetFps;
|
|
} else {
|
|
this.serverFps = clamp(this.serverFps, this.config.minSourceFps, nextTargetFps);
|
|
this.deliveryFps = clamp(this.deliveryFps, this.config.minSourceFps, nextTargetFps);
|
|
this.sourceFps = clamp(this.sourceFps, this.config.minSourceFps, nextTargetFps);
|
|
this.renderFps = this.sourceFps * this.playbackRate;
|
|
}
|
|
this.latestChunkDurationMs = Math.max(this.latestChunkDurationMs, 1000 / this.targetFps);
|
|
}
|
|
|
|
clear() {
|
|
const frames = this.queue.splice(0);
|
|
this.lastDrawAt = 0;
|
|
this.buffering = true;
|
|
return frames;
|
|
}
|
|
|
|
noteInputEvent(eventId, now, { cutoverMode = "motion" } = {}) {
|
|
this.pendingEventId = Number(eventId || 0);
|
|
this.pendingEventSentAt = Number(now || 0);
|
|
this.pendingEventCutoverMode = cutoverMode;
|
|
}
|
|
|
|
observeServerStats(stats, now) {
|
|
const chunkIndex = Number(stats.chunk_index || 0);
|
|
const numFrames = Number(stats.num_frames || 0);
|
|
const chunkTotalMs = Number(stats.chunk_total_ms || 0);
|
|
if (numFrames > 0 && chunkTotalMs > 0) {
|
|
this.serverStatChunks.add(chunkIndex);
|
|
if (this.serverStatChunks.size > 128) {
|
|
this.serverStatChunks.delete(this.serverStatChunks.values().next().value);
|
|
}
|
|
const expectedMs = numFrames / Math.max(1, this.targetFps) * 1000;
|
|
const isStartupWarmup =
|
|
chunkIndex === 0 &&
|
|
chunkTotalMs > Math.max(
|
|
this.config.startupWarmupMinMs,
|
|
expectedMs * this.config.startupWarmupExpectedMultiplier,
|
|
);
|
|
if (isStartupWarmup) return this.snapshot();
|
|
this.#observeFpsSample("server", {
|
|
fps: numFrames / (chunkTotalMs / 1000),
|
|
frameCount: numFrames,
|
|
durationMs: chunkTotalMs,
|
|
now,
|
|
});
|
|
}
|
|
return this.snapshot();
|
|
}
|
|
|
|
enqueueDecodedFrames(header, frames, now) {
|
|
const chunkIndex = Number(header.chunk_index || 0);
|
|
const eventId = Number(header.event_id || 0);
|
|
const receivedAt = Number(header.__received_at || now);
|
|
const preparedFrames = frames.map((frame) => ({
|
|
...frame,
|
|
chunk: Number(frame.chunk ?? chunkIndex),
|
|
chunkIndex,
|
|
eventId,
|
|
}));
|
|
const droppedFrames = [];
|
|
let cutover = null;
|
|
|
|
if (this.pendingEventId && eventId >= this.pendingEventId) {
|
|
const oldEventFrameCount = this.#oldEventFrameCount(eventId);
|
|
const graceFrames = this.#eventGraceFrames();
|
|
const dropCount = Math.max(0, oldEventFrameCount - graceFrames);
|
|
if (dropCount > 0) {
|
|
droppedFrames.push(...this.queue.splice(graceFrames, dropCount));
|
|
this.#recordDrop(dropCount, "event cutover", now);
|
|
}
|
|
cutover = {
|
|
eventId,
|
|
latencyMs: this.pendingEventSentAt ? now - this.pendingEventSentAt : 0,
|
|
};
|
|
this.pendingEventId = 0;
|
|
this.pendingEventSentAt = 0;
|
|
this.pendingEventCutoverMode = "motion";
|
|
}
|
|
|
|
this.queue.push(...preparedFrames);
|
|
this.#observeChunkArrival(header, preparedFrames.length, receivedAt, now);
|
|
droppedFrames.push(...this.#trimBacklog(now));
|
|
return { droppedFrames, cutover, snapshot: this.snapshot() };
|
|
}
|
|
|
|
render(now, { hasPendingInput = true } = {}) {
|
|
this.#decayRebufferBoost(now);
|
|
this.#updateReceiveStallGuard(now);
|
|
const droppedFrames = this.#trimBacklog(now);
|
|
if (!this.queue.length) {
|
|
if (this.renderedFrames && hasPendingInput && !this.buffering) {
|
|
this.buffering = true;
|
|
this.rebufferLeadBoostMs = Math.max(
|
|
this.rebufferLeadBoostMs,
|
|
this.config.rebufferLeadBoostMs,
|
|
);
|
|
}
|
|
return { action: "hold", droppedFrames, snapshot: this.snapshot() };
|
|
}
|
|
|
|
const bufferMs = this.bufferDurationMs;
|
|
if (
|
|
hasPendingInput &&
|
|
this.receiveStalled &&
|
|
this.renderedFrames &&
|
|
bufferMs < this.targetLeadMs
|
|
) {
|
|
this.buffering = true;
|
|
this.lastDrawAt = 0;
|
|
return { action: "hold", droppedFrames, snapshot: this.snapshot() };
|
|
}
|
|
|
|
if (
|
|
hasPendingInput &&
|
|
this.buffering &&
|
|
bufferMs < (this.renderedFrames ? this.#resumeLeadMs() : this.#startLeadMs())
|
|
) {
|
|
this.buffering = true;
|
|
this.lastDrawAt = 0;
|
|
return { action: "hold", droppedFrames, snapshot: this.snapshot() };
|
|
}
|
|
|
|
if (this.buffering) {
|
|
this.buffering = false;
|
|
this.lastDrawAt = 0;
|
|
}
|
|
|
|
this.#updatePlaybackRate(now);
|
|
const targetMs = 1000 / Math.max(1, this.renderFps);
|
|
const elapsedMs = this.lastDrawAt ? now - this.lastDrawAt : targetMs;
|
|
if (elapsedMs < targetMs) {
|
|
return { action: "wait", droppedFrames, snapshot: this.snapshot() };
|
|
}
|
|
|
|
const frame = this.queue.shift();
|
|
this.renderedFrames += 1;
|
|
this.lastDrawAt = !this.lastDrawAt || elapsedMs > targetMs * 4
|
|
? now
|
|
: now - (elapsedMs % targetMs);
|
|
return { action: "draw", frame, droppedFrames, snapshot: this.snapshot() };
|
|
}
|
|
|
|
get queuedFrames() {
|
|
return this.queue.length;
|
|
}
|
|
|
|
get bufferDurationMs() {
|
|
return this.queue.length / Math.max(1, this.sourceFps) * 1000;
|
|
}
|
|
|
|
get targetLeadMs() {
|
|
const base = clamp(
|
|
this.latestChunkDurationMs * this.config.targetLeadChunkRatio,
|
|
this.config.minTargetLeadMs,
|
|
this.config.maxTargetLeadMs,
|
|
);
|
|
return clamp(
|
|
base + this.rebufferLeadBoostMs + this.deliveryLeadBoostMs,
|
|
this.config.minTargetLeadMs,
|
|
this.config.maxTargetLeadMs +
|
|
this.config.rebufferLeadBoostMs +
|
|
this.config.maxDeliveryLeadBoostMs,
|
|
);
|
|
}
|
|
|
|
get maxLeadMs() {
|
|
return this.targetLeadMs + this.latestChunkDurationMs * this.config.maxLeadExtraChunkRatio;
|
|
}
|
|
|
|
snapshot() {
|
|
return {
|
|
queueFrames: this.queue.length,
|
|
bufferMs: this.bufferDurationMs,
|
|
targetLeadMs: this.targetLeadMs,
|
|
maxLeadMs: this.maxLeadMs,
|
|
sourceFps: this.sourceFps,
|
|
serverFps: this.serverFps,
|
|
deliveryFps: this.deliveryFps,
|
|
targetFps: this.targetFps,
|
|
renderFps: this.renderFps,
|
|
playbackRate: this.playbackRate,
|
|
droppedFrames: this.droppedFrames,
|
|
lastDropAt: this.lastDropAt,
|
|
lastDropCount: this.lastDropCount,
|
|
buffering: this.buffering,
|
|
lastDropReason: this.lastDropReason,
|
|
};
|
|
}
|
|
|
|
#observeFpsSample(kind, { fps, frameCount, durationMs, now }) {
|
|
if (!finitePositive(fps)) return;
|
|
const cappedFps = clamp(fps, this.config.minSourceFps, this.targetFps);
|
|
const isDelivery = kind === "delivery";
|
|
const currentFps = isDelivery ? this.deliveryFps : this.serverFps;
|
|
const hasSample = isDelivery ? this.hasDeliverySample : this.hasServerSample;
|
|
let nextFps;
|
|
if (!hasSample) {
|
|
nextFps = cappedFps;
|
|
} else {
|
|
const alpha = cappedFps > currentFps
|
|
? (isDelivery ? this.config.deliveryFpsAlphaUp : this.config.serverFpsAlphaUp)
|
|
: (isDelivery ? this.config.deliveryFpsAlphaDown : this.config.serverFpsAlphaDown);
|
|
nextFps = currentFps * (1 - alpha) + cappedFps * alpha;
|
|
}
|
|
if (isDelivery) {
|
|
this.deliveryFps = nextFps;
|
|
this.hasDeliverySample = true;
|
|
this.#observeDeliveryJitter(frameCount, durationMs);
|
|
} else {
|
|
this.serverFps = nextFps;
|
|
this.hasServerSample = true;
|
|
}
|
|
const effectiveFps = this.hasServerSample
|
|
? this.serverFps
|
|
: (this.hasDeliverySample ? this.deliveryFps : this.targetFps);
|
|
this.sourceFps = clamp(effectiveFps, this.config.minSourceFps, this.targetFps);
|
|
if (!isDelivery || !this.hasServerSample) {
|
|
this.latestChunkFrames = Math.max(1, Number(frameCount || this.latestChunkFrames));
|
|
this.latestChunkDurationMs = clamp(
|
|
Number(durationMs || (this.latestChunkFrames / Math.max(1, this.sourceFps) * 1000)),
|
|
1000 / Math.max(1, this.targetFps),
|
|
2500,
|
|
);
|
|
}
|
|
this.#updatePlaybackRate(now);
|
|
}
|
|
|
|
#observeDeliveryJitter(frameCount, durationMs) {
|
|
if (!this.hasServerSample || !finitePositive(durationMs)) return;
|
|
const expectedMs = Number(frameCount || 0) / Math.max(1, this.serverFps) * 1000;
|
|
if (expectedMs <= 0) return;
|
|
if (durationMs <= expectedMs * this.config.deliveryStallExpectedMultiplier) return;
|
|
const boostMs = clamp(
|
|
durationMs - expectedMs,
|
|
0,
|
|
this.config.maxDeliveryLeadBoostMs,
|
|
);
|
|
this.deliveryLeadBoostMs = Math.max(this.deliveryLeadBoostMs, boostMs);
|
|
}
|
|
|
|
#updateReceiveStallGuard(now) {
|
|
this.receiveStalled = false;
|
|
if (!this.lastFinalReceiveAt || !this.hasServerSample) return;
|
|
const elapsedMs = now - this.lastFinalReceiveAt;
|
|
const expectedMs = Math.max(
|
|
this.latestChunkDurationMs,
|
|
this.latestChunkFrames / Math.max(1, this.serverFps) * 1000,
|
|
);
|
|
if (elapsedMs <= expectedMs * this.config.deliveryStallExpectedMultiplier) return;
|
|
this.receiveStalled = true;
|
|
this.deliveryLeadBoostMs = Math.max(
|
|
this.deliveryLeadBoostMs,
|
|
clamp(elapsedMs - expectedMs, 0, this.config.maxDeliveryLeadBoostMs),
|
|
);
|
|
}
|
|
|
|
#observeChunkArrival(header, frameCount, receivedAt, now) {
|
|
const chunkIndex = Number(header.chunk_index || 0);
|
|
const state = this.chunkReceives.get(chunkIndex) || {
|
|
firstAt: receivedAt,
|
|
frames: 0,
|
|
};
|
|
state.frames += Number(frameCount || 0);
|
|
state.lastAt = receivedAt;
|
|
this.chunkReceives.set(chunkIndex, state);
|
|
|
|
const frameBatchIndex = Number(header.frame_batch_index || 0);
|
|
const numFrameBatches = Number(header.num_frame_batches || 1);
|
|
const isFinalFrameBatch =
|
|
Boolean(header.is_final_frame_batch) ||
|
|
frameBatchIndex + 1 >= numFrameBatches;
|
|
if (!isFinalFrameBatch) return;
|
|
const durationMs = this.lastFinalReceiveAt
|
|
? receivedAt - this.lastFinalReceiveAt
|
|
: 0;
|
|
this.lastFinalReceiveAt = receivedAt;
|
|
if (state.frames > 0 && durationMs > 0) {
|
|
this.#observeFpsSample("delivery", {
|
|
fps: state.frames / (durationMs / 1000),
|
|
frameCount: state.frames,
|
|
durationMs,
|
|
now,
|
|
});
|
|
}
|
|
this.chunkReceives.delete(chunkIndex);
|
|
}
|
|
|
|
#updatePlaybackRate(now) {
|
|
const bufferMs = this.bufferDurationMs;
|
|
const targetLeadMs = Math.max(1, this.targetLeadMs);
|
|
const error = (bufferMs - targetLeadMs) / targetLeadMs;
|
|
const emergency =
|
|
bufferMs > this.maxLeadMs ||
|
|
bufferMs < targetLeadMs * this.config.lowWaterRatio ||
|
|
(this.receiveStalled && bufferMs < targetLeadMs);
|
|
const minRate = emergency
|
|
? (
|
|
this.receiveStalled
|
|
? this.config.receiveStallPlaybackRateMin
|
|
: this.config.emergencyPlaybackRateMin
|
|
)
|
|
: this.config.playbackRateMin;
|
|
const maxRate = this.receiveStalled && bufferMs < targetLeadMs
|
|
? 1
|
|
: emergency
|
|
? this.config.emergencyPlaybackRateMax
|
|
: this.config.playbackRateMax;
|
|
const desiredRate = clamp(
|
|
1 + error * this.config.playbackRateGain,
|
|
minRate,
|
|
maxRate,
|
|
);
|
|
|
|
if (!this.lastRateUpdateAt) {
|
|
this.playbackRate = desiredRate;
|
|
} else {
|
|
const dtSeconds = Math.max(0.001, (now - this.lastRateUpdateAt) / 1000);
|
|
const slewPerSecond = this.receiveStalled
|
|
? this.config.receiveStallPlaybackRateSlewPerSecond
|
|
: this.config.playbackRateSlewPerSecond;
|
|
const maxDelta = slewPerSecond * dtSeconds;
|
|
this.playbackRate = clamp(
|
|
desiredRate,
|
|
this.playbackRate - maxDelta,
|
|
this.playbackRate + maxDelta,
|
|
);
|
|
}
|
|
this.lastRateUpdateAt = now;
|
|
this.renderFps = clamp(
|
|
this.sourceFps * this.playbackRate,
|
|
this.config.minSourceFps,
|
|
this.targetFps * this.config.emergencyPlaybackRateMax,
|
|
);
|
|
}
|
|
|
|
#trimBacklog(now) {
|
|
const droppedFrames = [];
|
|
while (this.queue.length && this.bufferDurationMs > this.maxLeadMs) {
|
|
const firstChunk = this.queue[0].chunkIndex;
|
|
let dropCount = 0;
|
|
while (
|
|
dropCount < this.queue.length &&
|
|
this.queue[dropCount].chunkIndex === firstChunk
|
|
) {
|
|
dropCount += 1;
|
|
}
|
|
if (!dropCount || dropCount >= this.queue.length) break;
|
|
droppedFrames.push(...this.queue.splice(0, dropCount));
|
|
this.#recordDrop(dropCount, "backlog", now);
|
|
}
|
|
return droppedFrames;
|
|
}
|
|
|
|
#oldEventFrameCount(nextEventId) {
|
|
let count = 0;
|
|
while (count < this.queue.length && this.queue[count].eventId < nextEventId) {
|
|
count += 1;
|
|
}
|
|
return count;
|
|
}
|
|
|
|
#eventGraceFrames() {
|
|
const byTime = Math.max(
|
|
1,
|
|
Math.round(this.sourceFps * this.#eventCutoverMaxMs() / 1000),
|
|
);
|
|
const byChunkRatio = this.pendingEventCutoverMode === "settle" ? 1.5 : 0.85;
|
|
const byChunk = Math.max(1, Math.round(this.latestChunkFrames * byChunkRatio));
|
|
return Math.min(this.#eventCutoverMaxFrames(), byTime, byChunk);
|
|
}
|
|
|
|
#eventCutoverMaxMs() {
|
|
return this.pendingEventCutoverMode === "settle"
|
|
? this.config.settleEventCutoverMaxMs
|
|
: this.config.eventCutoverMaxMs;
|
|
}
|
|
|
|
#eventCutoverMaxFrames() {
|
|
return this.pendingEventCutoverMode === "settle"
|
|
? this.config.settleEventCutoverMaxFrames
|
|
: this.config.eventCutoverMaxFrames;
|
|
}
|
|
|
|
#startLeadMs() {
|
|
return Math.max(
|
|
this.config.minStartLeadMs,
|
|
this.latestChunkDurationMs * this.config.startLeadChunkRatio,
|
|
this.targetLeadMs,
|
|
);
|
|
}
|
|
|
|
#resumeLeadMs() {
|
|
return clamp(
|
|
this.latestChunkDurationMs * this.config.resumeLeadChunkRatio,
|
|
this.config.minResumeLeadMs,
|
|
this.config.maxResumeLeadMs,
|
|
);
|
|
}
|
|
|
|
#decayRebufferBoost(now) {
|
|
if ((!this.rebufferLeadBoostMs && !this.deliveryLeadBoostMs) || !this.lastRateUpdateAt) return;
|
|
const dtSeconds = Math.max(0, (now - this.lastRateUpdateAt) / 1000);
|
|
this.rebufferLeadBoostMs = Math.max(
|
|
0,
|
|
this.rebufferLeadBoostMs - dtSeconds * this.config.rebufferLeadBoostDecayMsPerSecond,
|
|
);
|
|
this.deliveryLeadBoostMs = Math.max(
|
|
0,
|
|
this.deliveryLeadBoostMs - dtSeconds * this.config.deliveryLeadBoostDecayMsPerSecond,
|
|
);
|
|
}
|
|
|
|
#recordDrop(count, reason, now) {
|
|
this.droppedFrames += count;
|
|
this.lastDropAt = Number(now || 0);
|
|
this.lastDropCount = count;
|
|
this.lastDropReason = reason;
|
|
}
|
|
}
|
|
|
|
global.RealtimePlaybackController = RealtimePlaybackController;
|
|
if (typeof module !== "undefined" && module.exports) {
|
|
module.exports = { RealtimePlaybackController };
|
|
}
|
|
})(typeof globalThis !== "undefined" ? globalThis : window);
|