Skip to content

Commit

Permalink
@uppy/status-bar: Filtered ETA (#4458)
Browse files Browse the repository at this point in the history
  • Loading branch information
stduhpf authored May 24, 2023
1 parent f31fd97 commit 0e3be10
Show file tree
Hide file tree
Showing 4 changed files with 104 additions and 25 deletions.
80 changes: 55 additions & 25 deletions packages/@uppy/status-bar/src/StatusBar.jsx
Original file line number Diff line number Diff line change
@@ -1,33 +1,14 @@
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'

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) {
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -103,14 +92,52 @@ 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()

if (recoveredState) {
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
})
Expand All @@ -130,7 +157,6 @@ export default class StatusBar extends UIPlugin {
newFiles,
startedFiles,
completeFiles,
inProgressNotPausedFiles,

isUploadStarted,
isAllComplete,
Expand All @@ -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

Expand All @@ -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,
Expand Down
1 change: 1 addition & 0 deletions packages/@uppy/utils/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
17 changes: 17 additions & 0 deletions packages/@uppy/utils/src/emaFilter.js
Original file line number Diff line number Diff line change
@@ -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 changes: 31 additions & 0 deletions packages/@uppy/utils/src/emaFilter.test.js
Original file line number Diff line number Diff line change
@@ -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)))
}
})
})

0 comments on commit 0e3be10

Please sign in to comment.