diff --git a/BUNDLE-README.md b/BUNDLE-README.md
index 19d7c854c..f537d98e9 100644
--- a/BUNDLE-README.md
+++ b/BUNDLE-README.md
@@ -2,7 +2,7 @@
Hi, thanks for trying out the bundled version of the Uppy File Uploader. You can
use this from a CDN
-(``)
+(``)
or bundle it with your webapp.
Note that the recommended way to use Uppy is to install it with yarn/npm and use
diff --git a/README.md b/README.md
index 4fc71a92e..2ab19c7c6 100644
--- a/README.md
+++ b/README.md
@@ -103,7 +103,7 @@ npm install @uppy/core @uppy/dashboard @uppy/tus
```
Add CSS
-[uppy.min.css](https://releases.transloadit.com/uppy/v5.1.12/uppy.min.css),
+[uppy.min.css](https://releases.transloadit.com/uppy/v5.2.0/uppy.min.css),
either to your HTML page’s `
` or include in JS, if your bundler of choice
supports it.
@@ -117,7 +117,7 @@ CDN. In that case `Uppy` will attach itself to the global `window.Uppy` object.
```html
@@ -128,7 +128,7 @@ CDN. In that case `Uppy` will attach itself to the global `window.Uppy` object.
Uppy,
Dashboard,
Tus,
- } from 'https://releases.transloadit.com/uppy/v5.1.12/uppy.min.mjs'
+ } from 'https://releases.transloadit.com/uppy/v5.2.0/uppy.min.mjs'
const uppy = new Uppy()
uppy.use(Dashboard, { target: '#files-drag-drop' })
diff --git a/examples/nextjs/package.json b/examples/nextjs/package.json
index 6deb903be..b0058532d 100644
--- a/examples/nextjs/package.json
+++ b/examples/nextjs/package.json
@@ -16,7 +16,7 @@
"@uppy/transloadit": "workspace:*",
"@uppy/tus": "workspace:*",
"@uppy/xhr-upload": "workspace:*",
- "next": "15.5.2",
+ "next": "15.5.7",
"react": "19.1.0",
"react-dom": "19.1.0"
},
diff --git a/packages/@uppy/image-generator/CHANGELOG.md b/packages/@uppy/image-generator/CHANGELOG.md
new file mode 100644
index 000000000..3222febdd
--- /dev/null
+++ b/packages/@uppy/image-generator/CHANGELOG.md
@@ -0,0 +1,14 @@
+# @uppy/image-generator
+
+## 1.0.0
+
+### Major Changes
+
+- 5684efa: Introduce @uppy/image-generator to generate images based on a prompt using Transloadit
+
+### Patch Changes
+
+- Updated dependencies [5684efa]
+- Updated dependencies [5684efa]
+ - @uppy/provider-views@5.2.1
+ - @uppy/transloadit@5.4.0
diff --git a/packages/@uppy/image-generator/LICENSE b/packages/@uppy/image-generator/LICENSE
new file mode 100644
index 000000000..d074cabcb
--- /dev/null
+++ b/packages/@uppy/image-generator/LICENSE
@@ -0,0 +1,21 @@
+The MIT License (MIT)
+
+Copyright (c) 2020 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.
diff --git a/packages/@uppy/image-generator/README.md b/packages/@uppy/image-generator/README.md
new file mode 100644
index 000000000..0a35b9d95
--- /dev/null
+++ b/packages/@uppy/image-generator/README.md
@@ -0,0 +1,49 @@
+# @uppy/image-generator
+
+
+
+[](https://www.npmjs.com/package/@uppy/image-generator)
+
+
+
+
+**[Read the docs](https://uppy.io/docs/image-generator)** |
+**[Try it](https://uppy.io/examples/)**
+
+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 ImageGenerator from '@uppy/image-generator'
+
+const uppy = new Uppy()
+ .use(ImageGenerator, {
+ assemblyOptions: async (prompt) => {
+ const res = await fetch(`/assembly-options?prompt=${encodeURIComponent(prompt)}`)
+ return res.json()
+ }
+ })
+```
+
+## Installation
+
+```bash
+$ npm install @uppy/image-generator
+```
+
+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/image-generator).
+
+## License
+
+[The MIT License](./LICENSE).
diff --git a/packages/@uppy/image-generator/package.json b/packages/@uppy/image-generator/package.json
new file mode 100644
index 000000000..4b555a518
--- /dev/null
+++ b/packages/@uppy/image-generator/package.json
@@ -0,0 +1,60 @@
+{
+ "name": "@uppy/image-generator",
+ "description": "AI-powered image generation for Uppy",
+ "version": "1.0.0",
+ "license": "MIT",
+ "sideEffects": false,
+ "type": "module",
+ "keywords": [
+ "file uploader",
+ "upload",
+ "uppy",
+ "uppy-plugin",
+ "ai",
+ "image generation"
+ ],
+ "homepage": "https://uppy.io",
+ "bugs": {
+ "url": "https://github.com/transloadit/uppy/issues"
+ },
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/transloadit/uppy.git"
+ },
+ "files": [
+ "src",
+ "lib",
+ "dist",
+ "CHANGELOG.md"
+ ],
+ "exports": {
+ ".": "./lib/index.js",
+ "./css/style.css": "./dist/style.css",
+ "./css/style.min.css": "./dist/style.min.css",
+ "./package.json": "./package.json"
+ },
+ "scripts": {
+ "build": "tsc --build tsconfig.build.json",
+ "build:css": "sass --load-path=../../ src/style.scss dist/style.css && postcss dist/style.css -u cssnano -o dist/style.min.css",
+ "typecheck": "tsc --build"
+ },
+ "dependencies": {
+ "@uppy/provider-views": "workspace:^",
+ "@uppy/transloadit": "workspace:^",
+ "@uppy/utils": "workspace:^",
+ "preact": "^10.5.13"
+ },
+ "devDependencies": {
+ "@uppy/core": "workspace:^",
+ "cssnano": "^7.0.7",
+ "postcss": "^8.5.6",
+ "postcss-cli": "^11.0.1",
+ "sass": "^1.89.2"
+ },
+ "peerDependencies": {
+ "@uppy/core": "workspace:^"
+ },
+ "publishConfig": {
+ "access": "public"
+ }
+}
diff --git a/packages/@uppy/image-generator/src/index.tsx b/packages/@uppy/image-generator/src/index.tsx
new file mode 100644
index 000000000..8fc002e32
--- /dev/null
+++ b/packages/@uppy/image-generator/src/index.tsx
@@ -0,0 +1,381 @@
+import type { Body, Meta, MinimalRequiredUppyFile, Uppy } from '@uppy/core'
+import { UIPlugin, type UIPluginOptions } from '@uppy/core'
+import { FilterInput, SearchView } from '@uppy/provider-views'
+import {
+ Assembly,
+ type AssemblyResult,
+ Client,
+ type OptionsWithRestructuredFields,
+} from '@uppy/transloadit'
+import { RateLimitedQueue } from '@uppy/utils'
+import type { h } from 'preact'
+import locale from './locale.js'
+
+export interface ImageGeneratorOptions extends UIPluginOptions {
+ // OptionsWithRestructuredFields does not allow string[] for `fields`.
+ // in @uppy/transloadit we do accept that but then immediately use a type assertion to this type
+ // so that's why we just don't allow string[] from the start here
+ assemblyOptions: (prompt: string) => Promise
+}
+
+interface PluginState extends Record {
+ prompt: string
+ results: AssemblyResult[]
+ checkedResultIds: Set
+ loading: boolean
+ loadingMessageIndex: number
+ firstRun: boolean
+}
+
+const defaultState = {
+ prompt: '',
+ results: [],
+ checkedResultIds: new Set(),
+ loading: false,
+ loadingMessageIndex: 0,
+ firstRun: true,
+} satisfies PluginState
+
+const LOADING_MESSAGES = [
+ 'generating1',
+ 'generating2',
+ 'generating3',
+ 'generating4',
+ 'generating5',
+] as const
+
+export default class ImageGenerator<
+ M extends Meta,
+ B extends Body,
+> extends UIPlugin {
+ private loadingInterval: ReturnType | null = null
+ private rateLimitedQueue: RateLimitedQueue
+ private client: Client
+ private assembly: Assembly | null = null
+ icon: () => h.JSX.Element
+
+ constructor(uppy: Uppy, opts: ImageGeneratorOptions) {
+ super(uppy, opts)
+
+ this.id = this.opts.id || 'ImageGenerator'
+ this.title = 'AI image'
+ this.type = 'acquirer'
+
+ this.defaultLocale = locale
+
+ this.rateLimitedQueue = new RateLimitedQueue(10)
+ this.client = new Client({
+ service: 'https://api2.transloadit.com',
+ rateLimitedQueue: this.rateLimitedQueue,
+ errorReporting: true,
+ })
+
+ this.setPluginState(defaultState)
+
+ this.i18nInit()
+
+ this.icon = () => (
+
+ )
+ }
+
+ install(): void {
+ const { target } = this.opts
+ if (target) {
+ this.mount(target, this)
+ }
+ }
+
+ uninstall(): void {
+ this.clearLoadingInterval()
+ this.closeAssembly(true) // Cancel any in-progress assembly
+ this.unmount()
+ }
+
+ private closeAssembly(cancel = false): void {
+ if (this.assembly) {
+ const { status } = this.assembly
+ this.assembly.close()
+ this.assembly = null
+
+ // Cancel the assembly on the server to stop processing
+ if (cancel && status) {
+ this.client.cancelAssembly(status).catch(() => {
+ // If we can't cancel, there's not much we can do
+ })
+ }
+ }
+ }
+
+ private clearLoadingInterval(): void {
+ if (this.loadingInterval) {
+ clearInterval(this.loadingInterval)
+ this.loadingInterval = null
+ }
+ }
+
+ private startLoadingAnimation(): void {
+ this.clearLoadingInterval()
+ this.loadingInterval = setInterval(() => {
+ const { loadingMessageIndex } = this.getPluginState()
+ const nextIndex = (loadingMessageIndex + 1) % LOADING_MESSAGES.length
+ this.setPluginState({ loadingMessageIndex: nextIndex })
+ }, 4000)
+ }
+
+ /**
+ * Creates a Transloadit assembly to generate AI images.
+ *
+ * Completion scenarios:
+ * - Success: assembly emits 'finished' → resolve() → finally cleans up, keeps results
+ * - Error: assembly emits 'error' → reject() → catch reports error, finally cleans up
+ * - Dashboard close: onDashboardClose sets cancelled=true, resolve() → finally resets state
+ * - Uninstall: closeAssembly(true) called directly, cancels server-side assembly
+ */
+ generate = async () => {
+ const { loading, prompt } = this.getPluginState()
+ if (loading || prompt.trim() === '') return
+
+ const { promise, resolve, reject } = Promise.withResolvers()
+ let cancelled = false
+
+ const onDashboardClose = () => {
+ cancelled = true
+ resolve()
+ }
+
+ // @ts-expect-error not typed because we do not depend on @uppy/dashboard
+ this.uppy.once('dashboard:close-panel', onDashboardClose)
+
+ try {
+ this.setPluginState({
+ loading: true,
+ results: [],
+ checkedResultIds: new Set(),
+ loadingMessageIndex: 0,
+ })
+ this.startLoadingAnimation()
+
+ const assemblyOptions = await this.opts.assemblyOptions(
+ this.getPluginState().prompt,
+ )
+
+ const assemblyResponse = await this.client.createAssembly({
+ params: assemblyOptions.params,
+ fields: assemblyOptions.fields ?? {},
+ signature: assemblyOptions.signature,
+ expectedFiles: 0,
+ })
+
+ const assembly = new Assembly(assemblyResponse, this.rateLimitedQueue)
+ this.assembly = assembly
+
+ assembly.on('result', (stepName: string, result: AssemblyResult) => {
+ const { results } = this.getPluginState()
+ this.setPluginState({
+ results: [...results, result],
+ firstRun: false,
+ })
+ })
+
+ assembly.on('error', reject)
+ assembly.on('finished', resolve)
+ assembly.connect()
+
+ await promise
+ } catch (error) {
+ this.client.submitError(error as Error).catch(() => {})
+ this.uppy.info('Image could not be generated', 'error')
+ throw error
+ } finally {
+ // @ts-expect-error not typed because we do not depend on @uppy/dashboard
+ this.uppy.off('dashboard:close-panel', onDashboardClose)
+ this.clearLoadingInterval()
+ this.closeAssembly(true)
+ this.setPluginState(cancelled ? defaultState : { loading: false })
+ }
+ }
+
+ private onCheckboxChange = (result: AssemblyResult, event?: Event) => {
+ if (event) {
+ event.stopPropagation()
+ event.preventDefault()
+ // Prevent shift-clicking from highlighting file names
+ document.getSelection()?.removeAllRanges()
+ }
+
+ const { checkedResultIds } = this.getPluginState()
+
+ if (checkedResultIds.has(result.id)) {
+ checkedResultIds.delete(result.id)
+ } else {
+ checkedResultIds.add(result.id)
+ }
+
+ this.setPluginState({ checkedResultIds })
+ }
+
+ private cancelSelection = () => {
+ this.setPluginState({ checkedResultIds: new Set() })
+ }
+
+ private donePicking = async () => {
+ const { checkedResultIds, results } = this.getPluginState()
+ const proms: Promise>[] = results
+ .filter((result) => checkedResultIds.has(result.id))
+ .map(async (result) => {
+ const res = await fetch(result.ssl_url!)
+ const blob = await res.blob()
+
+ return {
+ name: `ai-image-${result.id!}`,
+ type: result.mime ?? undefined,
+ source: 'Transloadit',
+ data: blob,
+ }
+ })
+ const files = await Promise.all(proms)
+
+ this.uppy.addFiles(files)
+ this.setPluginState(defaultState)
+ }
+
+ render() {
+ const {
+ prompt,
+ results,
+ checkedResultIds,
+ loading,
+ loadingMessageIndex,
+ firstRun,
+ } = this.getPluginState()
+ const { i18n } = this.uppy
+
+ const currentLoadingMessage = loading
+ ? i18n(LOADING_MESSAGES[loadingMessageIndex])
+ : undefined
+
+ if (firstRun) {
+ return (
+ this.setPluginState({ prompt })}
+ onSubmit={this.generate}
+ inputLabel={i18n('generateImagePlaceholder')}
+ loading={loading}
+ >
+ {loading ? (
+
+ {currentLoadingMessage}
+
+ ) : (
+ i18n('generateImage')
+ )}
+
+ )
+ }
+
+ return (
+