Forráskód Böngészése

@uppy/compressor: Add image compressor plugin (#3471)

* Add Compressor plugin

* Set type in blob if it’s missing

* clear thumbnail-generator queue when cancel-all is called

* Add e2e test for @uppy/compressor

* Docs, types, readme, bundle, add event

* Update yarn.lock

* fix test

* Update e2e/cypress/integration/dashboard-compressor.spec.ts

Co-authored-by: Antoine du Hamel <duhamelantoine1995@gmail.com>

* Update dashboard-compressor.spec.ts

* convert compressor to ESM

* Update e2e/clients/dashboard-compressor/index.html

Co-authored-by: Antoine du Hamel <duhamelantoine1995@gmail.com>

* remove console.log

* uglierBytes

Co-authored-by: Antoine du Hamel <duhamelantoine1995@gmail.com>
Artur Paikin 3 éve
szülő
commit
7223af2c9d

+ 16 - 0
e2e/clients/dashboard-compressor/app.js

@@ -0,0 +1,16 @@
+import Uppy from '@uppy/core'
+import Dashboard from '@uppy/dashboard'
+import Compressor from '@uppy/compressor'
+
+import '@uppy/core/dist/style.css'
+import '@uppy/dashboard/dist/style.css'
+
+const uppy = new Uppy()
+  .use(Dashboard, {
+    target: document.body,
+    inline: true,
+  })
+  .use(Compressor)
+
+// Keep this here to access uppy in tests
+window.uppy = uppy

+ 11 - 0
e2e/clients/dashboard-compressor/index.html

@@ -0,0 +1,11 @@
+<!doctype html>
+<html lang="en">
+  <head>
+    <meta charset="utf-8"/>
+    <title>dashboard-compressor</title>
+    <script defer type="module" src="app.js"></script>
+  </head>
+  <body>
+    <div id="app"></div>
+  </body>
+</html>

+ 1 - 0
e2e/clients/index.html

@@ -13,6 +13,7 @@
 				<li><a href="dashboard-ui/index.html">dashboard-ui</a></li>
 				<li><a href="dashboard-react/index.html">dashboard-react</a></li>
 				<li><a href="dashboard-vue/index.html">dashboard-vue</a></li>
+				<li><a href="dashboard-compressor/index.html">dashboard-compressor</a></li>
 			</ul>
     <nav>
   </body>

+ 52 - 0
e2e/cypress/integration/dashboard-compressor.spec.ts

@@ -0,0 +1,52 @@
+function uglierBytes (text) {
+  const KB = 2 ** 10
+  const MB = KB * KB
+
+  if (text.endsWith(' KB')) {
+    return Number(text.slice(0, -3)) * KB
+  }
+
+  if (text.endsWith(' MB')) {
+    return Number(text.slice(0, -3)) * MB
+  }
+
+  if (text.endsWith(' B')) {
+    return Number(text.slice(0, -2))
+  }
+
+  throw new Error(`Not what the computer thinks a human-readable size string look like:  ${text}`)
+}
+
+describe('dashboard-compressor', () => {
+  beforeEach(() => {
+    cy.visit('/dashboard-compressor')
+    cy.get('.uppy-Dashboard-input').as('file-input')
+  })
+
+  it('should compress images', () => {
+    const sizeBeforeCompression = []
+
+    cy.get('@file-input').attachFile(['images/cat.jpg', 'images/traffic.jpg'])
+
+    cy.get('.uppy-Dashboard-Item-statusSize').each((element) => {
+      const text = element.text()
+      sizeBeforeCompression.push(uglierBytes(text))
+    })
+
+    cy.get('.uppy-StatusBar-actionBtn--upload').click()
+
+    cy.get('.uppy-Informer p[role="alert"]', {
+      timeout: 10000,
+    }).should('be.visible')
+
+    cy.get('.uppy-Dashboard-Item-statusSize').should((elements) => {
+      expect(elements).to.have.length(sizeBeforeCompression.length)
+
+      for (let i = 0; i < elements.length; i++) {
+        expect(sizeBeforeCompression[i]).to.be.greaterThan(
+          uglierBytes(elements[i].textContent),
+        )
+      }
+    })
+  })
+})

+ 21 - 0
packages/@uppy/compressor/LICENSE

@@ -0,0 +1,21 @@
+The MIT License (MIT)
+
+Copyright (c) 2022 Transloadit
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.

+ 37 - 0
packages/@uppy/compressor/README.md

@@ -0,0 +1,37 @@
+# @uppy/compressor
+
+<img src="https://uppy.io/images/logos/uppy-dog-head-arrow.svg" width="120" alt="Uppy logo: a superman puppy in a pink suit" align="right">
+
+<a href="https://www.npmjs.com/package/@uppy/compressor"><img src="https://img.shields.io/npm/v/@uppy/compressor.svg?style=flat-square"></a> <img src="https://github.com/transloadit/uppy/workflows/Tests/badge.svg" alt="CI status for Uppy tests"> <img src="https://github.com/transloadit/uppy/workflows/Companion/badge.svg" alt="CI status for Companion tests"> <img src="https://github.com/transloadit/uppy/workflows/End-to-end%20tests/badge.svg" alt="CI status for browser tests">
+
+The Compressor plugin for Uppy optimizes images (JPEG, PNG, WEBP), saving on average up to 60% in size (roughly 18 MB for 10 images). It uses [Compressor.js](https://github.com/fengyuanchen/compressorjs).
+
+Uppy is being developed by the folks at [Transloadit](https://transloadit.com), a versatile file encoding service.
+
+## Example
+
+```js
+import Uppy from '@uppy/core'
+import Compressor from '@uppy/compressor'
+
+const uppy = new Uppy()
+uppy.use(Compressor)
+```
+
+## Installation
+
+```bash
+npm install @uppy/compressor
+```
+
+We recommend installing from yarn or npm, and then using a module bundler such as [Parcel](https://parceljs.org/), [Vite](https://vitejs.dev/) or [Webpack](https://webpack.js.org/).
+
+Alternatively, you can also use this plugin in a pre-built bundle from Transloadit’s CDN: Edgly. In that case `Uppy` will attach itself to the global `window.Uppy` object. See the [main Uppy documentation](https://uppy.io/docs/#Installation) for instructions.
+
+## Documentation
+
+Documentation for this plugin can be found on the [Uppy website](https://uppy.io/docs/compressor).
+
+## License
+
+[The MIT License](./LICENSE).

+ 38 - 0
packages/@uppy/compressor/package.json

@@ -0,0 +1,38 @@
+{
+  "name": "@uppy/compressor",
+  "description": "Uppy plugin that compresses images before upload, saving up to 60% in size",
+  "version": "0.2.1",
+  "license": "MIT",
+  "main": "lib/index.js",
+  "style": "dist/style.min.css",
+  "types": "types/index.d.ts",
+  "keywords": [
+    "file uploader",
+    "uppy",
+    "uppy-plugin",
+    "compress",
+    "image compression"
+  ],
+  "type": "module",
+  "homepage": "https://uppy.io",
+  "bugs": {
+    "url": "https://github.com/transloadit/uppy/issues"
+  },
+  "repository": {
+    "type": "git",
+    "url": "git+https://github.com/transloadit/uppy.git"
+  },
+  "dependencies": {
+    "@transloadit/prettier-bytes": "^0.0.9",
+    "@uppy/utils": "workspace:^",
+    "compressorjs": "^1.1.1",
+    "preact": "^10.5.13",
+    "promise-queue": "^2.2.5"
+  },
+  "peerDependencies": {
+    "@uppy/core": "workspace:^"
+  },
+  "publishConfig": {
+    "access": "public"
+  }
+}

+ 114 - 0
packages/@uppy/compressor/src/index.js

@@ -0,0 +1,114 @@
+import { BasePlugin } from '@uppy/core'
+import { RateLimitedQueue } from '@uppy/utils/lib/RateLimitedQueue'
+import prettierBytes from '@transloadit/prettier-bytes'
+import CompressorJS from 'compressorjs/dist/compressor.common.js'
+import locale from './locale.js'
+
+export default class Compressor extends BasePlugin {
+  #RateLimitedQueue
+
+  constructor (uppy, opts) {
+    super(uppy, opts)
+    this.id = this.opts.id || 'Compressor'
+    this.type = 'modifier'
+
+    this.defaultLocale = locale
+
+    const defaultOptions = {
+      quality: 0.6,
+      limit: 10,
+    }
+
+    this.opts = { ...defaultOptions, ...opts }
+
+    this.#RateLimitedQueue = new RateLimitedQueue(this.opts.limit)
+
+    this.i18nInit()
+
+    this.prepareUpload = this.prepareUpload.bind(this)
+    this.compress = this.compress.bind(this)
+  }
+
+  compress (blob) {
+    return new Promise((resolve, reject) => {
+      /* eslint-disable no-new */
+      new CompressorJS(blob, {
+        ...this.opts,
+        success: resolve,
+        error: reject,
+      })
+    })
+  }
+
+  async prepareUpload (fileIDs) {
+    let totalCompressedSize = 0
+    const compressAndApplyResult = this.#RateLimitedQueue.wrapPromiseFunction(
+      async (file) => {
+        try {
+          const compressedBlob = await this.compress(file.data)
+          this.uppy.log(`[Image Compressor] Image ${file.id} size before/after compression: ${file.data.size} / ${compressedBlob.size}`)
+          totalCompressedSize += compressedBlob.size
+          this.uppy.setFileState(file.id, {
+            data: compressedBlob,
+            size: compressedBlob.size,
+          })
+        } catch (err) {
+          this.uppy.log(`[Image Compressor] Failed to compress ${file.id}:`, 'warning')
+          this.uppy.log(err, 'warning')
+        }
+      },
+    )
+
+    const promises = fileIDs.map((fileID) => {
+      const file = this.uppy.getFile(fileID)
+      this.uppy.emit('preprocess-progress', file, {
+        mode: 'indeterminate',
+        message: this.i18n('compressingImages'),
+      })
+
+      // Some browsers (Firefox) add blobs with empty file type, when files are
+      // added from a folder. Uppy auto-detects type from extension, but leaves the original blob intact.
+      // However, Compressor.js failes when file has no type, so we set it here
+      if (!file.data.type) {
+        file.data = file.data.slice(0, file.data.size, file.type)
+      }
+
+      if (!file.type.startsWith('image/')) {
+        return Promise.resolve()
+      }
+
+      return compressAndApplyResult(file)
+    })
+
+    // Why emit `preprocess-complete` for all files at once, instead of
+    // above when each is processed?
+    // Because it leads to StatusBar showing a weird “upload 6 files” button,
+    // while waiting for all the files to complete pre-processing.
+    await Promise.all(promises)
+
+    this.uppy.emit('compressor:complete')
+
+    // Only show informer if Compressor mananged to save at least a kilobyte
+    if (totalCompressedSize > 1024) {
+      this.uppy.info(
+        this.i18n('compressedX', {
+          size: prettierBytes(totalCompressedSize),
+        }),
+        'info',
+      )
+    }
+
+    for (const fileID of fileIDs) {
+      const file = this.uppy.getFile(fileID)
+      this.uppy.emit('preprocess-complete', file)
+    }
+  }
+
+  install () {
+    this.uppy.addPreProcessor(this.prepareUpload)
+  }
+
+  uninstall () {
+    this.uppy.removePreProcessor(this.prepareUpload)
+  }
+}

+ 7 - 0
packages/@uppy/compressor/src/locale.js

@@ -0,0 +1,7 @@
+export default {
+  strings: {
+    // Shown in the Status Bar
+    compressingImages: 'Compressing images...',
+    compressedX: 'Saved %{size} by compressing images',
+  },
+}

+ 12 - 0
packages/@uppy/compressor/types/index.d.ts

@@ -0,0 +1,12 @@
+import type { PluginOptions, BasePlugin } from '@uppy/core'
+import type CompressorLocale from './generatedLocale'
+
+export interface CompressorOptions extends PluginOptions {
+  quality?: number
+  limit?: number
+  locale?: CompressorLocale
+}
+
+declare class Compressor extends BasePlugin<CompressorOptions> {}
+
+export default Compressor

+ 7 - 0
packages/@uppy/compressor/types/index.test-d.ts

@@ -0,0 +1,7 @@
+import Uppy from '@uppy/core'
+import Compressor from '..'
+
+{
+  const uppy = new Uppy()
+  uppy.use(Compressor)
+}

+ 8 - 0
packages/@uppy/thumbnail-generator/src/index.js

@@ -329,6 +329,10 @@ module.exports = class ThumbnailGenerator extends UIPlugin {
     })
   }
 
+  onAllFilesRemoved = () => {
+    this.queue = []
+  }
+
   waitUntilAllProcessed = (fileIDs) => {
     fileIDs.forEach((fileID) => {
       const file = this.uppy.getFile(fileID)
@@ -360,6 +364,8 @@ module.exports = class ThumbnailGenerator extends UIPlugin {
 
   install () {
     this.uppy.on('file-removed', this.onFileRemoved)
+    this.uppy.on('cancel-all', this.onAllFilesRemoved)
+
     if (this.opts.lazy) {
       this.uppy.on('thumbnail:request', this.onFileAdded)
       this.uppy.on('thumbnail:cancel', this.onCancelRequest)
@@ -375,6 +381,8 @@ module.exports = class ThumbnailGenerator extends UIPlugin {
 
   uninstall () {
     this.uppy.off('file-removed', this.onFileRemoved)
+    this.uppy.off('cancel-all', this.onAllFilesRemoved)
+
     if (this.opts.lazy) {
       this.uppy.off('thumbnail:request', this.onFileAdded)
       this.uppy.off('thumbnail:cancel', this.onCancelRequest)

+ 3 - 3
packages/@uppy/thumbnail-generator/src/index.test.js

@@ -51,7 +51,7 @@ describe('uploader/ThumbnailGeneratorPlugin', () => {
       plugin.addToQueue = jest.fn()
       plugin.install()
 
-      expect(core.on).toHaveBeenCalledTimes(3)
+      expect(core.on).toHaveBeenCalledTimes(4)
       expect(core.on).toHaveBeenCalledWith('file-added', plugin.onFileAdded)
     })
   })
@@ -67,11 +67,11 @@ describe('uploader/ThumbnailGeneratorPlugin', () => {
       plugin.addToQueue = jest.fn()
       plugin.install()
 
-      expect(core.on).toHaveBeenCalledTimes(3)
+      expect(core.on).toHaveBeenCalledTimes(4)
 
       plugin.uninstall()
 
-      expect(core.off).toHaveBeenCalledTimes(3)
+      expect(core.off).toHaveBeenCalledTimes(4)
       expect(core.off).toHaveBeenCalledWith('file-added', plugin.onFileAdded)
     })
   })

+ 1 - 0
packages/uppy/index.js

@@ -49,5 +49,6 @@ exports.Form = require('@uppy/form')
 exports.GoldenRetriever = require('@uppy/golden-retriever')
 exports.ReduxDevTools = require('@uppy/redux-dev-tools')
 exports.ThumbnailGenerator = require('@uppy/thumbnail-generator')
+exports.Compressor = require('@uppy/compressor')
 
 exports.locales = {}

+ 1 - 0
packages/uppy/package.json

@@ -36,6 +36,7 @@
     "@uppy/aws-s3-multipart": "workspace:^",
     "@uppy/box": "workspace:^",
     "@uppy/companion-client": "workspace:^",
+    "@uppy/compressor": "workspace:^",
     "@uppy/core": "workspace:^",
     "@uppy/dashboard": "workspace:^",
     "@uppy/drag-drop": "workspace:^",

+ 73 - 0
website/src/docs/compressor.md

@@ -0,0 +1,73 @@
+---
+type: docs
+order: 10
+title: "Compressor"
+module: "@uppy/compressor"
+permalink: docs/compressor/
+category: "Miscellaneous"
+tagline: "optimizes images before upload, saving up to 60% on average"
+---
+
+The Compressor plugin for Uppy optimizes images (JPEG, PNG), saving on average up to 60% in size (roughly 18 MB for 10 images). It uses [Compressor.js](https://github.com/fengyuanchen/compressorjs).
+
+```js
+import Uppy from '@uppy/core'
+import Compressor from '@uppy/compressor'
+
+const uppy = new Uppy()
+uppy.use(Compressor)
+```
+
+## Installation
+
+This plugin is published as the `@uppy/compressor` package.
+
+```shell
+npm install @uppy/compressor
+```
+
+In the [CDN package](/docs/#With-a-script-tag), the plugin class is available on the `Uppy` global object:
+
+```js
+const { Compressor } = Uppy
+```
+
+## Options
+
+The `@uppy/compressor` plugin has the following configurable options:
+
+```js
+uppy.use(Compressor, {
+  quality: 0.6,
+  limit: 10,
+})
+```
+
+### `id`
+
+* Type: `string`
+* Default: `Compressor`
+
+A unique identifier for this plugin. It defaults to `'Compressor'`.
+
+### `quality`
+
+* Type: `number`
+* Default: `0.8`
+
+This option is passed to [Compressor.js](https://github.com/fengyuanchen/compressorjs).
+
+The quality of the output image. It must be a number between `0` and `1`. If this argument is anything else, the default values `0.92` and `0.80` are used for `image/jpeg` and `image/webp` respectively. Other arguments are ignored. Be careful to use `1` as it may make the size of the output image become larger.
+
+**Note:** This option only available for `image/jpeg` and `image/webp` images.
+
+### `limit`
+
+* Type: `number`
+* Default: `10`
+
+Number of images that will be compressed in parallel. You likely don’t need to change this, unless you are experiencing performance issues.
+
+## Event
+
+`compressor:complete` event is emitted when all files are compressed.

+ 53 - 0
yarn.lock

@@ -7192,6 +7192,13 @@ __metadata:
   languageName: node
   linkType: hard
 
+"@transloadit/prettier-bytes@npm:^0.0.9":
+  version: 0.0.9
+  resolution: "@transloadit/prettier-bytes@npm:0.0.9"
+  checksum: efa5a723c41e7bce7ad17d1affe6a43209df857e17dc2b12a7c7bd6d3c921df8298086dbfb62ed740ca3e617d8c7f47485bb311adb637b20f2f75a28b08bac4f
+  languageName: node
+  linkType: hard
+
 "@trysound/sax@npm:0.2.0":
   version: 0.2.0
   resolution: "@trysound/sax@npm:0.2.0"
@@ -9172,6 +9179,20 @@ __metadata:
   languageName: unknown
   linkType: soft
 
+"@uppy/compressor@workspace:^, @uppy/compressor@workspace:packages/@uppy/compressor":
+  version: 0.0.0-use.local
+  resolution: "@uppy/compressor@workspace:packages/@uppy/compressor"
+  dependencies:
+    "@transloadit/prettier-bytes": ^0.0.9
+    "@uppy/utils": "workspace:^"
+    compressorjs: ^1.1.1
+    preact: ^10.5.13
+    promise-queue: ^2.2.5
+  peerDependencies:
+    "@uppy/core": "workspace:^"
+  languageName: unknown
+  linkType: soft
+
 "@uppy/core@workspace:*, @uppy/core@workspace:^, @uppy/core@workspace:packages/@uppy/core":
   version: 0.0.0-use.local
   resolution: "@uppy/core@workspace:packages/@uppy/core"
@@ -12714,6 +12735,13 @@ __metadata:
   languageName: node
   linkType: hard
 
+"blueimp-canvas-to-blob@npm:^3.29.0":
+  version: 3.29.0
+  resolution: "blueimp-canvas-to-blob@npm:3.29.0"
+  checksum: 6a55b90fbe958b75f7c78ff9e7617c01254d29cc8567ea6c853cd26a52518b3dfce28635f6964130ac738ee8ff9fb9c0ca094db2ceeaa021ff0432c1985416eb
+  languageName: node
+  linkType: hard
+
 "blueimp-md5@npm:^2.10.0":
   version: 2.19.0
   resolution: "blueimp-md5@npm:2.19.0"
@@ -14625,6 +14653,16 @@ __metadata:
   languageName: node
   linkType: hard
 
+"compressorjs@npm:^1.1.1":
+  version: 1.1.1
+  resolution: "compressorjs@npm:1.1.1"
+  dependencies:
+    blueimp-canvas-to-blob: ^3.29.0
+    is-blob: ^2.1.0
+  checksum: b4899567df07ebd0f2d3bdc814a6e63b0013be0020e119c0aa4fd4292298a0a4251302855c6ec6c40dd676c0e02f9a4483868f81474229cc375b928bb9c3f086
+  languageName: node
+  linkType: hard
+
 "compute-scroll-into-view@npm:^1.0.17":
   version: 1.0.17
   resolution: "compute-scroll-into-view@npm:1.0.17"
@@ -23368,6 +23406,13 @@ hexo-filter-github-emojis@arturi/hexo-filter-github-emojis:
   languageName: node
   linkType: hard
 
+"is-blob@npm:^2.1.0":
+  version: 2.1.0
+  resolution: "is-blob@npm:2.1.0"
+  checksum: ce24917bf50a52736e37702a14bc729a0e1eab6bf61ac0b8e6ee86caf33d73eba297131736f8e54615732d08168859493ba7ced4fc840bb5b7c2ae21a5de6861
+  languageName: node
+  linkType: hard
+
 "is-boolean-object@npm:^1.0.1, is-boolean-object@npm:^1.1.0":
   version: 1.1.2
   resolution: "is-boolean-object@npm:1.1.2"
@@ -33397,6 +33442,13 @@ hexo-filter-github-emojis@arturi/hexo-filter-github-emojis:
   languageName: node
   linkType: hard
 
+"promise-queue@npm:^2.2.5":
+  version: 2.2.5
+  resolution: "promise-queue@npm:2.2.5"
+  checksum: 41dc832a0674ea74f5f9bb4812566769d862eb52a19bdf3773045429f07b2bc433af5d939baaf4930191e37202ea31ec57e43a5a2994c101cb729069fe3eac6a
+  languageName: node
+  linkType: hard
+
 "promise-retry@npm:^2.0.1":
   version: 2.0.1
   resolution: "promise-retry@npm:2.0.1"
@@ -40890,6 +40942,7 @@ hexo-filter-github-emojis@arturi/hexo-filter-github-emojis:
     "@uppy/aws-s3-multipart": "workspace:^"
     "@uppy/box": "workspace:^"
     "@uppy/companion-client": "workspace:^"
+    "@uppy/compressor": "workspace:^"
     "@uppy/core": "workspace:^"
     "@uppy/dashboard": "workspace:^"
     "@uppy/drag-drop": "workspace:^"