frameMotionWindow.js 3.6 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879
  1. /** 运动帧间隔窗口(票 59 采样口径修正)。
  2. *
  3. * 为什么需要:导出里的 movingFrameIntervals 曾把"静止→运动"的第一段也收进窗口,
  4. * 而这一段包含按需渲染(requestRenderMode)的空档,几百毫秒的假尖峰正是这样来的;
  5. * 反过来,真正的长停顿又因 1000ms 上限被静默丢弃、连计数都没有。
  6. *
  7. * 现在的口径:只收**连续运动帧之间**的间隔(上一帧也在运动)。起步段、隐藏期、
  8. * 以及 >=maxGapMs 的空档都不进窗口,但都会被**计数**并保留最大被排除值,
  9. * 所以既去掉起步空档,也不会把长停顿藏起来。
  10. */
  11. export const MOTION_WINDOW_DEFAULTS = { maxSamples: 240, maxGapMs: 1000 }
  12. /**
  13. * @param {{maxSamples?: number, maxGapMs?: number}} [options]
  14. * @returns {{push: (tMs: number, moving: boolean) => string, markGap: (tMs: number) => void,
  15. * reset: () => void, snapshot: () => object}}
  16. */
  17. export function createMotionIntervalWindow(options = {}) {
  18. const configured = Number(options.maxSamples)
  19. const maxSamples = Number.isFinite(configured) && configured >= 1
  20. ? Math.trunc(configured)
  21. : MOTION_WINDOW_DEFAULTS.maxSamples
  22. const ceiling = Number(options.maxGapMs)
  23. const maxGapMs = Number.isFinite(ceiling) ? ceiling : MOTION_WINDOW_DEFAULTS.maxGapMs
  24. let intervals = []
  25. let previous = null
  26. let restartGapsExcluded = 0
  27. let longGapsExcluded = 0
  28. let maxExcludedMs = null
  29. const exclude = (kind, dt) => {
  30. if (kind === 'restart-gap') restartGapsExcluded += 1
  31. else longGapsExcluded += 1
  32. if (maxExcludedMs === null || dt > maxExcludedMs) maxExcludedMs = dt
  33. return kind
  34. }
  35. return {
  36. /** @returns {'first'|'sampled'|'restart-gap'|'long-gap'|'idle'} */
  37. push(tMs, moving) {
  38. const at = Number(tMs)
  39. if (!Number.isFinite(at)) return 'idle'
  40. const isMoving = !!moving
  41. if (previous === null) { previous = { at, moving: isMoving }; return 'first' }
  42. const dt = at - previous.at
  43. const wasMoving = previous.moving
  44. previous = { at, moving: isMoving }
  45. if (!isMoving || dt <= 0) return 'idle'
  46. if (!wasMoving) return exclude('restart-gap', dt)
  47. if (dt >= maxGapMs) return exclude('long-gap', dt)
  48. intervals.push(dt)
  49. if (intervals.length > maxSamples) intervals.shift()
  50. return 'sampled'
  51. },
  52. /** 隐藏期间的帧不采样,但必须打断"连续运动",否则恢复可见的首段会被当成连续帧。 */
  53. markGap(tMs) {
  54. const at = Number(tMs)
  55. if (Number.isFinite(at)) previous = { at, moving: false }
  56. },
  57. reset() {
  58. intervals = []
  59. previous = null
  60. restartGapsExcluded = 0
  61. longGapsExcluded = 0
  62. maxExcludedMs = null
  63. },
  64. snapshot() {
  65. const sorted = [...intervals].sort((a, b) => a - b)
  66. return {
  67. samples: sorted.length,
  68. p50Ms: sorted.length ? +sorted[Math.floor(sorted.length * .5)].toFixed(2) : null,
  69. p95Ms: sorted.length ? +sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * .95))].toFixed(2) : null,
  70. maxMs: sorted.length ? +sorted[sorted.length - 1].toFixed(2) : null,
  71. over50Ms: sorted.filter(n => n > 50).length,
  72. restartGapsExcluded,
  73. longGapsExcluded,
  74. maxExcludedMs: maxExcludedMs === null ? null : +maxExcludedMs.toFixed(2),
  75. }
  76. },
  77. }
  78. }