/** 运动帧间隔窗口(票 59 采样口径修正)。 * * 为什么需要:导出里的 movingFrameIntervals 曾把"静止→运动"的第一段也收进窗口, * 而这一段包含按需渲染(requestRenderMode)的空档,几百毫秒的假尖峰正是这样来的; * 反过来,真正的长停顿又因 1000ms 上限被静默丢弃、连计数都没有。 * * 现在的口径:只收**连续运动帧之间**的间隔(上一帧也在运动)。起步段、隐藏期、 * 以及 >=maxGapMs 的空档都不进窗口,但都会被**计数**并保留最大被排除值, * 所以既去掉起步空档,也不会把长停顿藏起来。 */ export const MOTION_WINDOW_DEFAULTS = { maxSamples: 240, maxGapMs: 1000 } /** * @param {{maxSamples?: number, maxGapMs?: number}} [options] * @returns {{push: (tMs: number, moving: boolean) => string, markGap: (tMs: number) => void, * reset: () => void, snapshot: () => object}} */ export function createMotionIntervalWindow(options = {}) { const configured = Number(options.maxSamples) const maxSamples = Number.isFinite(configured) && configured >= 1 ? Math.trunc(configured) : MOTION_WINDOW_DEFAULTS.maxSamples const ceiling = Number(options.maxGapMs) const maxGapMs = Number.isFinite(ceiling) ? ceiling : MOTION_WINDOW_DEFAULTS.maxGapMs let intervals = [] let previous = null let restartGapsExcluded = 0 let longGapsExcluded = 0 let maxExcludedMs = null const exclude = (kind, dt) => { if (kind === 'restart-gap') restartGapsExcluded += 1 else longGapsExcluded += 1 if (maxExcludedMs === null || dt > maxExcludedMs) maxExcludedMs = dt return kind } return { /** @returns {'first'|'sampled'|'restart-gap'|'long-gap'|'idle'} */ push(tMs, moving) { const at = Number(tMs) if (!Number.isFinite(at)) return 'idle' const isMoving = !!moving if (previous === null) { previous = { at, moving: isMoving }; return 'first' } const dt = at - previous.at const wasMoving = previous.moving previous = { at, moving: isMoving } if (!isMoving || dt <= 0) return 'idle' if (!wasMoving) return exclude('restart-gap', dt) if (dt >= maxGapMs) return exclude('long-gap', dt) intervals.push(dt) if (intervals.length > maxSamples) intervals.shift() return 'sampled' }, /** 隐藏期间的帧不采样,但必须打断"连续运动",否则恢复可见的首段会被当成连续帧。 */ markGap(tMs) { const at = Number(tMs) if (Number.isFinite(at)) previous = { at, moving: false } }, reset() { intervals = [] previous = null restartGapsExcluded = 0 longGapsExcluded = 0 maxExcludedMs = null }, snapshot() { const sorted = [...intervals].sort((a, b) => a - b) return { samples: sorted.length, p50Ms: sorted.length ? +sorted[Math.floor(sorted.length * .5)].toFixed(2) : null, p95Ms: sorted.length ? +sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * .95))].toFixed(2) : null, maxMs: sorted.length ? +sorted[sorted.length - 1].toFixed(2) : null, over50Ms: sorted.filter(n => n > 50).length, restartGapsExcluded, longGapsExcluded, maxExcludedMs: maxExcludedMs === null ? null : +maxExcludedMs.toFixed(2), } }, } }