소스 검색

@uppy/status-bar: Filtered ETA (#4458)

stduhpf 1 년 전
부모
커밋
0e3be10317
4개의 변경된 파일104개의 추가작업 그리고 25개의 파일을 삭제
  1. 55 25
      packages/@uppy/status-bar/src/StatusBar.jsx
  2. 1 0
      packages/@uppy/utils/package.json
  3. 17 0
      packages/@uppy/utils/src/emaFilter.js
  4. 31 0
      packages/@uppy/utils/src/emaFilter.test.js

+ 55 - 25
packages/@uppy/status-bar/src/StatusBar.jsx

@@ -1,6 +1,5 @@
 import { UIPlugin } from '@uppy/core'
-import getSpeed from '@uppy/utils/lib/getSpeed'
-import getBytesRemaining from '@uppy/utils/lib/getBytesRemaining'
+import emaFilter from '@uppy/utils/lib/emaFilter'
 import getTextDirection from '@uppy/utils/lib/getTextDirection'
 import statusBarStates from './StatusBarStates.js'
 import StatusBarUI from './StatusBarUI.jsx'
@@ -8,26 +7,8 @@ import StatusBarUI from './StatusBarUI.jsx'
 import packageJson from '../package.json'
 import locale from './locale.js'
 
-function getTotalSpeed (files) {
-  let totalSpeed = 0
-  files.forEach((file) => {
-    totalSpeed += getSpeed(file.progress)
-  })
-  return totalSpeed
-}
-
-function getTotalETA (files) {
-  const totalSpeed = getTotalSpeed(files)
-  if (totalSpeed === 0) {
-    return 0
-  }
-
-  const totalBytesRemaining = files.reduce((total, file) => {
-    return total + getBytesRemaining(file.progress)
-  }, 0)
-
-  return Math.round((totalBytesRemaining / totalSpeed) * 10) / 10
-}
+const speedFilterHalfLife = 2000
+const ETAFilterHalfLife = 2000
 
 function getUploadingState (error, isAllComplete, recoveredState, files) {
   if (error) {
@@ -75,6 +56,14 @@ function getUploadingState (error, isAllComplete, recoveredState, files) {
 export default class StatusBar extends UIPlugin {
   static VERSION = packageJson.version
 
+  #lastUpdateTime
+
+  #previousUploadedBytes
+
+  #previousSpeed
+
+  #previousETA
+
   constructor (uppy, opts) {
     super(uppy, opts)
     this.id = this.opts.id || 'StatusBar'
@@ -103,6 +92,41 @@ export default class StatusBar extends UIPlugin {
     this.install = this.install.bind(this)
   }
 
+  #computeSmoothETA (totalBytes) {
+    if (totalBytes.total === 0 || totalBytes.remaining === 0) {
+      return 0
+    }
+
+    const dt = performance.now() - this.#lastUpdateTime
+    if (dt === 0) {
+      return Math.round((this.#previousETA ?? 0) / 100) / 10
+    }
+
+    const uploadedBytesSinceLastTick = totalBytes.uploaded - this.#previousUploadedBytes
+    this.#previousUploadedBytes = totalBytes.uploaded
+
+    // uploadedBytesSinceLastTick can be negative in some cases (packet loss?)
+    // in which case, we wait for next tick to update ETA.
+    if (uploadedBytesSinceLastTick <= 0) {
+      return Math.round((this.#previousETA ?? 0) / 100) / 10
+    }
+    const currentSpeed = uploadedBytesSinceLastTick / dt
+    const filteredSpeed = this.#previousSpeed == null
+      ? currentSpeed
+      : emaFilter(currentSpeed, this.#previousSpeed, speedFilterHalfLife, dt)
+    this.#previousSpeed = filteredSpeed
+    const instantETA = totalBytes.remaining / filteredSpeed
+
+    const updatedPreviousETA = Math.max(this.#previousETA - dt, 0)
+    const filteredETA = this.#previousETA == null
+      ? instantETA
+      : emaFilter(instantETA, updatedPreviousETA, ETAFilterHalfLife, dt)
+    this.#previousETA = filteredETA
+    this.#lastUpdateTime = performance.now()
+
+    return Math.round(filteredETA / 100) / 10
+  }
+
   startUpload = () => {
     const { recoveredState } = this.uppy.getState()
 
@@ -110,7 +134,10 @@ export default class StatusBar extends UIPlugin {
       this.uppy.emit('restore-confirmed')
       return undefined
     }
-
+    this.#lastUpdateTime = performance.now()
+    this.#previousUploadedBytes = 0
+    this.#previousSpeed = null
+    this.#previousETA = null
     return this.uppy.upload().catch(() => {
       // Error logged in Core
     })
@@ -130,7 +157,6 @@ export default class StatusBar extends UIPlugin {
       newFiles,
       startedFiles,
       completeFiles,
-      inProgressNotPausedFiles,
 
       isUploadStarted,
       isAllComplete,
@@ -146,7 +172,6 @@ export default class StatusBar extends UIPlugin {
     const newFilesOrRecovered = recoveredState
       ? Object.values(files)
       : newFiles
-    const totalETA = getTotalETA(inProgressNotPausedFiles)
     const resumableUploads = !!capabilities.resumableUploads
     const supportsUploadProgress = capabilities.uploadProgress !== false
 
@@ -157,6 +182,11 @@ export default class StatusBar extends UIPlugin {
       totalSize += file.progress.bytesTotal || 0
       totalUploadedSize += file.progress.bytesUploaded || 0
     })
+    const totalETA = this.#computeSmoothETA({
+      uploaded: totalUploadedSize,
+      total: totalSize,
+      remaining: totalSize - totalUploadedSize,
+    })
 
     return StatusBarUI({
       error,

+ 1 - 0
packages/@uppy/utils/package.json

@@ -26,6 +26,7 @@
     "./lib/canvasToBlob": "./lib/canvasToBlob.js",
     "./lib/dataURItoBlob": "./lib/dataURItoBlob.js",
     "./lib/dataURItoFile": "./lib/dataURItoFile.js",
+    "./lib/emaFilter": "./lib/emaFilter.js",
     "./lib/emitSocketProgress": "./lib/emitSocketProgress.js",
     "./lib/findAllDOMElements": "./lib/findAllDOMElements.js",
     "./lib/findDOMElement": "./lib/findDOMElement.js",

+ 17 - 0
packages/@uppy/utils/src/emaFilter.js

@@ -0,0 +1,17 @@
+/**
+ * Low-pass filter using Exponential Moving Averages (aka exponential smoothing)
+ * Filters a sequence of values by updating the mixing the previous output value
+ * with the new input using the exponential window function
+ *
+ * @param {*} newValue the n-th value of the sequence
+ * @param {*} previousSmoothedValue the exponential average of the first n-1 values
+ * @param {*} halfLife value of `dt` to move the smoothed value halfway between `previousFilteredValue` and `newValue`
+ * @param {*} dt time elapsed between adding the (n-1)th and the n-th values
+ * @returns the exponential average of the first n values
+ */
+export default function emaFilter (newValue, previousSmoothedValue, halfLife, dt) {
+  if (halfLife === 0 || newValue === previousSmoothedValue) return newValue
+  if (dt === 0) return previousSmoothedValue
+
+  return newValue + (previousSmoothedValue - newValue) * (2 ** (-dt / halfLife))
+}

+ 31 - 0
packages/@uppy/utils/src/emaFilter.test.js

@@ -0,0 +1,31 @@
+import { describe, expect, it } from '@jest/globals'
+import emaFilter from './emaFilter.js'
+
+describe('emaFilter', () => {
+  it('should calculate the exponential average', () => {
+    expect(emaFilter(1, 0, 0, 1)).toBe(1)
+
+    expect(emaFilter(1, 0, 2, 0)).toBe(0)
+    expect(emaFilter(1, 0, 2, 2)).toBeCloseTo(0.5)
+    expect(emaFilter(1, 0, 2, 4)).toBeCloseTo(0.75)
+    expect(emaFilter(1, 0, 2, 6)).toBeCloseTo(0.875)
+
+    expect(emaFilter(0, 1, 2, 2)).toBeCloseTo(0.5)
+    expect(emaFilter(0, 1, 2, 4)).toBeCloseTo(0.25)
+    expect(emaFilter(0, 1, 2, 6)).toBeCloseTo(0.125)
+
+    expect(emaFilter(0.5, 1, 2, 4)).toBeCloseTo(0.625)
+    expect(emaFilter(1, 0.5, 2, 4)).toBeCloseTo(0.875)
+  })
+  it('should behave like exponential moving average', () => {
+    const firstValue = 1
+    const newValue = 10
+    const step = 0.618033989
+    const halfLife = 2
+    let lastFilteredValue = firstValue
+    for (let i = 0; i < 10; ++i) {
+      lastFilteredValue = emaFilter(newValue, lastFilteredValue, halfLife, step)
+      expect(lastFilteredValue).toBeCloseTo(emaFilter(newValue, firstValue, halfLife, step * (i + 1)))
+    }
+  })
+})