Repository: nuxt/image
Pull request #864 (2023-06-10 03:29:48)

chore(deps): update devdependency @types/node to ^18.16.17
[![Mend Renovate](https://app.renovatebot.com/images/banner.svg)](https://renovatebot.com)

This PR contains the following updates:

| Package | Change | Age | Adoption | Passing | Confidence |
|---|---|---|---|---|---|
| [@types/node](https://togithub.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/node) ([source](https://togithub.com/DefinitelyTyped/DefinitelyTyped)) | [`^18.16.16` -> `^18.16.17`](https://renovatebot.com/diffs/npm/@types%2fnode/18.16.16/18.16.17) | [![age](https://badges.renovateapi.com/packages/npm/@types%2fnode/18.16.17/age-slim)](https://docs.renovatebot.com/merge-confidence/) | [![adoption](https://badges.renovateapi.com/packages/npm/@types%2fnode/18.16.17/adoption-slim)](https://docs.renovatebot.com/merge-confidence/) | [![passing](https://badges.renovateapi.com/packages/npm/@types%2fnode/18.16.17/compatibility-slim/18.16.16)](https://docs.renovatebot.com/merge-confidence/) | [![confidence](https://badges.renovateapi.com/packages/npm/@types%2fnode/18.16.17/confidence-slim/18.16.16)](https://docs.renovatebot.com/merge-confidence/) |

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined).

🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Mend Renovate](https://www.mend.io/free-developer-tools/renovate/). View repository job log [here](https://app.renovatebot.com/dashboard#github/nuxt/image).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiIzNS4xMTAuMCIsInVwZGF0ZWRJblZlciI6IjM1LjExMC4wIiwidGFyZ2V0QnJhbmNoIjoibWFpbiJ9-->


netlify[bot]: ### <span aria-hidden="true">👷</span> Deploy Preview for *nuxt-image* processing.


|  Name | Link |
|:-:|------------------------|
|<span aria-hidden="true">🔨</span> Latest commit | f7bf77582739091b18079df5fb42e1e7f15c5ac2 |
|<span aria-hidden="true">🔍</span> Latest deploy log | https://app.netlify.com/sites/nuxt-image/deploys/6483ee2ebd911a0008efb1b0 |

Patch:
diff --git a/docs/content/3.components/1.nuxt-img.md b/docs/content/3.components/1.nuxt-img.md
index aa2bff4..c564505 100644
--- a/docs/content/3.components/1.nuxt-img.md
+++ b/docs/content/3.components/1.nuxt-img.md
@@ -67,6 +67,22 @@ This a space-separated list of screen size/width pairs. You can [see a list of t
   />
 ```
 
+### `densities`
+
+To generate special versions of images for screens with increased pixel density.
+
+**Example:**
+
+```html
+<nuxt-img
+  src="/logos/nuxt.png"
+  height="50"
+  densities="x1 x2"
+  />
+<!-- <img src="/_ipx/w_50/logos/nuxt.png" 
+    srcset="/_ipx/w_100/logos/nuxt.png x2"/> -->
+```
+
 ### `provider`
 
 Use other provider instead of default [provider option](/configuration#provider) specified in `nuxt.config`
diff --git a/docs/content/5.configuration.md b/docs/content/5.configuration.md
index da8d2bf..2c5db20 100644
--- a/docs/content/5.configuration.md
+++ b/docs/content/5.configuration.md
@@ -154,6 +154,22 @@ export default defineNuxtConfig({
 })
 ```
 
+## `densities`
+
+Default: `[1, 2]`
+
+Specify a value to work with `devicePixelRatio` > 1 (these are devices with retina display and others). You must specify for which `devicePixelRatio` values you want to adapt images.
+
+You can [read more about `devicePixelRatio` on MDN](https://developer.mozilla.org/en-US/docs/Web/API/Window/devicePixelRatio).
+
+```ts [nuxt.config.ts]
+export default defineNuxtConfig({
+  image: {
+    densities: [1, 2, 3],
+  }
+})
+```
+
 ## `staticFilename`
 
 You can use this option to change filename and location for the static image generation.
diff --git a/package.json b/package.json
index d35b5bf..299605a 100755
--- a/package.json
+++ b/package.json
@@ -42,7 +42,7 @@
     "@nuxt/module-builder": "0.4.0",
     "@nuxt/test-utils": "^3.5.3",
     "@nuxtjs/eslint-config-typescript": "^12.0.0",
-    "@types/node": "^18.16.16",
+    "@types/node": "^18.16.17",
     "@vitest/coverage-c8": "^0.32.0",
     "@vue/test-utils": "^2.3.2",
     "changelogen": "^0.5.3",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index e9360e2..4b8ff0a 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -60,8 +60,8 @@ importers:
         specifier: ^12.0.0
         version: 12.0.0(eslint@8.42.0)(typescript@5.1.3)
       '@types/node':
-        specifier: ^18.16.16
-        version: 18.16.16
+        specifier: ^18.16.17
+        version: 18.16.17
       '@vitest/coverage-c8':
         specifier: ^0.32.0
         version: 0.32.0(vitest@0.32.0)
@@ -82,7 +82,7 @@ importers:
         version: 1.18.2
       nuxt:
         specifier: ^3.5.3
-        version: 3.5.3(@types/node@18.16.16)(eslint@8.42.0)(rollup@3.23.0)(typescript@5.1.3)(vue-tsc@1.6.5)
+        version: 3.5.3(@types/node@18.16.17)(eslint@8.42.0)(rollup@3.23.0)(typescript@5.1.3)(vue-tsc@1.6.5)
       nuxt-vitest:
         specifier: ^0.8.5
         version: 0.8.5(@vitejs/plugin-vue-jsx@3.0.1)(@vitejs/plugin-vue@4.2.3)(rollup@3.23.0)(vite@4.3.9)(vitest@0.32.0)(vue-router@4.2.2)(vue@3.3.4)
@@ -109,7 +109,7 @@ importers:
         version: 0.2.1(rollup@3.23.0)
       nuxt:
         specifier: ^3.5.3
-        version: 3.5.3(@types/node@18.16.16)(eslint@8.42.0)(rollup@3.23.0)(typescript@5.1.3)(vue-tsc@1.6.5)
+        version: 3.5.3(@types/node@18.16.17)(eslint@8.42.0)(rollup@3.23.0)(typescript@5.1.3)(vue-tsc@1.6.5)
 
   example:
     devDependencies:
@@ -118,7 +118,7 @@ importers:
         version: link:..
       nuxt:
         specifier: latest
-        version: 3.5.3(@types/node@18.16.16)(eslint@8.42.0)(rollup@3.23.0)(typescript@5.1.3)(vue-tsc@1.6.5)
+        version: 3.5.3(@types/node@18.16.17)(eslint@8.42.0)(rollup@3.23.0)(typescript@5.1.3)(vue-tsc@1.6.5)
 
   playground:
     devDependencies:
@@ -127,7 +127,7 @@ importers:
         version: link:..
       nuxt:
         specifier: latest
-        version: 3.5.3(@types/node@18.16.16)(eslint@8.42.0)(rollup@3.23.0)(typescript@5.1.3)(vue-tsc@1.6.5)
+        version: 3.5.3(@types/node@18.16.17)(eslint@8.42.0)(rollup@3.23.0)(typescript@5.1.3)(vue-tsc@1.6.5)
 
 packages:
 
@@ -1122,7 +1122,7 @@ packages:
     resolution: {integrity: sha512-PjVETP7+iZXAs5Q8O4ivl4t6qjWZMZqwiTVogUXHoHGZZcw7GZW3u3tzfYfE1HbzyYJfr236IXqQ02MeR8Fz2w==}
     dev: true
 
-  /@nuxt/vite-builder@3.5.3(@types/node@18.16.16)(eslint@8.42.0)(rollup@3.23.0)(typescript@5.1.3)(vue-tsc@1.6.5)(vue@3.3.4):
+  /@nuxt/vite-builder@3.5.3(@types/node@18.16.17)(eslint@8.42.0)(rollup@3.23.0)(typescript@5.1.3)(vue-tsc@1.6.5)(vue@3.3.4):
     resolution: {integrity: sha512-7zEKpGh3iWGRDwbWUa8eRxdLMxZtPzetelmdmXPjtYKGwUebZOcBhpeJ+VgJKOIf4OEj9E7BZS+it/Ji9UG9qw==}
     engines: {node: ^14.18.0 || >=16.10.0}
     peerDependencies:
@@ -1159,8 +1159,8 @@ packages:
       strip-literal: 1.0.1
       ufo: 1.1.2
       unplugin: 1.3.1
-      vite: 4.3.9(@types/node@18.16.16)
-      vite-node: 0.31.4(@types/node@18.16.16)
+      vite: 4.3.9(@types/node@18.16.17)
+      vite-node: 0.31.4(@types/node@18.16.17)
       vite-plugin-checker: 0.6.0(eslint@8.42.0)(typescript@5.1.3)(vite@4.3.9)(vue-tsc@1.6.5)
       vue: 3.3.4
       vue-bundle-renderer: 1.0.3
@@ -1454,7 +1454,7 @@ packages:
   /@types/http-proxy@1.17.11:
     resolution: {integrity: sha512-HC8G7c1WmaF2ekqpnFq626xd3Zz0uvaqFmBJNRZCGEZCXkvSdJoNFn/8Ygbd9fKNQj8UzLdCETaI0UWPAjK7IA==}
     dependencies:
-      '@types/node': 18.16.16
+      '@types/node': 18.16.17
     dev: true
 
   /@types/istanbul-lib-coverage@2.0.4:
@@ -1479,8 +1479,8 @@ packages:
     resolution: {integrity: sha512-iiUgKzV9AuaEkZqkOLDIvlQiL6ltuZd9tGcW3gwpnX8JbuiuhFlEGmmFXEXkN50Cvq7Os88IY2v0dkDqXYWVgA==}
     dev: true
 
-  /@types/node@18.16.16:
-    resolution: {integrity: sha512-NpaM49IGQQAUlBhHMF82QH80J08os4ZmyF9MkpCzWAGuOHqE4gTEbhzd7L3l5LmWuZ6E0OiC1FweQ4tsiW35+g==}
+  /@types/node@18.16.17:
+    resolution: {integrity: sha512-QAkjjRA1N7gPJeAP4WLXZtYv6+eMXFNviqktCDt4GLcmCugMr5BcRHfkOjCQzvCsnMp+L79a54zBkbw356xv9Q==}
     dev: true
 
   /@types/normalize-package-data@2.4.1:
@@ -1714,7 +1714,7 @@ packages:
       '@babel/core': 7.21.8
       '@babel/plugin-transform-typescript': 7.21.3(@babel/core@7.21.8)
       '@vue/babel-plugin-jsx': 1.1.1(@babel/core@7.21.8)
-      vite: 4.3.9(@types/node@18.16.16)
+      vite: 4.3.9(@types/node@18.16.17)
       vue: 3.3.4
     transitivePeerDependencies:
       - supports-color
@@ -1727,7 +1727,7 @@ packages:
       vite: ^4.0.0
       vue: ^3.2.25
     dependencies:
-      vite: 4.3.9(@types/node@18.16.16)
+      vite: 4.3.9(@types/node@18.16.17)
       vue: 3.3.4
     dev: true
 
@@ -2028,7 +2028,7 @@ packages:
       '@vueuse/core': 10.1.2(vue@3.3.4)
       '@vueuse/metadata': 10.1.2
       local-pkg: 0.4.3
-      nuxt: 3.5.3(@types/node@18.16.16)(eslint@8.42.0)(rollup@3.23.0)(typescript@5.1.3)(vue-tsc@1.6.5)
+      nuxt: 3.5.3(@types/node@18.16.17)(eslint@8.42.0)(rollup@3.23.0)(typescript@5.1.3)(vue-tsc@1.6.5)
       vue-demi: 0.14.5(vue@3.3.4)
     transitivePeerDependencies:
       - '@vue/composition-api'
@@ -6048,7 +6048,7 @@ packages:
       get-port-please: 3.0.1
       perfect-debounce: 1.0.0
       std-env: 3.3.3
-      vite: 4.3.9(@types/node@18.16.16)
+      vite: 4.3.9(@types/node@18.16.17)
       vitest: 0.32.0(playwright@1.35.0)
       vitest-environment-nuxt: 0.8.5(rollup@3.23.0)(vitest@0.32.0)(vue-router@4.2.2)(vue@3.3.4)
     transitivePeerDependencies:
@@ -6058,7 +6058,7 @@ packages:
       - vue-router
     dev: true
 
-  /nuxt@3.5.3(@types/node@18.16.16)(eslint@8.42.0)(rollup@3.23.0)(typescript@5.1.3)(vue-tsc@1.6.5):
+  /nuxt@3.5.3(@types/node@18.16.17)(eslint@8.42.0)(rollup@3.23.0)(typescript@5.1.3)(vue-tsc@1.6.5):
     resolution: {integrity: sha512-fG39BZ5N5ATtmx2vuxN8APQPSlSsCDpfkJ0k581gMc7eFztqrBzPncZX5w3RQLW7AiGBE2yYEfqiwC6AVODBBg==}
     engines: {node: ^14.18.0 || >=16.10.0}
     hasBin: true
@@ -6074,8 +6074,8 @@ packages:
       '@nuxt/schema': 3.5.3(rollup@3.23.0)
       '@nuxt/telemetry': 2.2.0(rollup@3.23.0)
       '@nuxt/ui-templates': 1.1.1
-      '@nuxt/vite-builder': 3.5.3(@types/node@18.16.16)(eslint@8.42.0)(rollup@3.23.0)(typescript@5.1.3)(vue-tsc@1.6.5)(vue@3.3.4)
-      '@types/node': 18.16.16
+      '@nuxt/vite-builder': 3.5.3(@types/node@18.16.17)(eslint@8.42.0)(rollup@3.23.0)(typescript@5.1.3)(vue-tsc@1.6.5)(vue@3.3.4)
+      '@types/node': 18.16.17
       '@unhead/ssr': 1.1.27
       '@unhead/vue': 1.1.27(vue@3.3.4)
       '@vue/shared': 3.3.4
@@ -8426,7 +8426,7 @@ packages:
       vfile-message: 3.1.4
     dev: true
 
-  /vite-node@0.31.4(@types/node@18.16.16):
+  /vite-node@0.31.4(@types/node@18.16.17):
     resolution: {integrity: sha512-uzL377GjJtTbuc5KQxVbDu2xfU/x0wVjUtXQR2ihS21q/NK6ROr4oG0rsSkBBddZUVCwzfx22in76/0ZZHXgkQ==}
     engines: {node: '>=v14.18.0'}
     hasBin: true
@@ -8436,7 +8436,7 @@ packages:
       mlly: 1.3.0
       pathe: 1.1.1
       picocolors: 1.0.0
-      vite: 4.3.9(@types/node@18.16.16)
+      vite: 4.3.9(@types/node@18.16.17)
     transitivePeerDependencies:
       - '@types/node'
       - less
@@ -8447,7 +8447,7 @@ packages:
       - terser
     dev: true
 
-  /vite-node@0.32.0(@types/node@18.16.16):
+  /vite-node@0.32.0(@types/node@18.16.17):
     resolution: {integrity: sha512-220P/y8YacYAU+daOAqiGEFXx2A8AwjadDzQqos6wSukjvvTWNqleJSwoUn0ckyNdjHIKoxn93Nh1vWBqEKr3Q==}
     engines: {node: '>=v14.18.0'}
     hasBin: true
@@ -8457,7 +8457,7 @@ packages:
       mlly: 1.3.0
       pathe: 1.1.1
       picocolors: 1.0.0
-      vite: 4.3.9(@types/node@18.16.16)
+      vite: 4.3.9(@types/node@18.16.17)
     transitivePeerDependencies:
       - '@types/node'
       - less
@@ -8514,7 +8514,7 @@ packages:
       strip-ansi: 6.0.1
       tiny-invariant: 1.3.1
       typescript: 5.1.3
-      vite: 4.3.9(@types/node@18.16.16)
+      vite: 4.3.9(@types/node@18.16.17)
       vscode-languageclient: 7.0.0
       vscode-languageserver: 7.0.0
       vscode-languageserver-textdocument: 1.0.7
@@ -8522,7 +8522,7 @@ packages:
       vue-tsc: 1.6.5(typescript@5.1.3)
     dev: true
 
-  /vite@4.3.9(@types/node@18.16.16):
+  /vite@4.3.9(@types/node@18.16.17):
     resolution: {integrity: sha512-qsTNZjO9NoJNW7KnOrgYwczm0WctJ8m/yqYAMAK9Lxt4SoySUfS5S8ia9K7JHpa3KEeMfyF8LoJ3c5NeBJy6pg==}
     engines: {node: ^14.18.0 || >=16.0.0}
     hasBin: true
@@ -8547,7 +8547,7 @@ packages:
       terser:
         optional: true
     dependencies:
-      '@types/node': 18.16.16
+      '@types/node': 18.16.17
       esbuild: 0.17.19
       postcss: 8.4.24
       rollup: 3.23.0
@@ -8612,7 +8612,7 @@ packages:
     dependencies:
       '@types/chai': 4.3.5
       '@types/chai-subset': 1.3.3
-      '@types/node': 18.16.16
+      '@types/node': 18.16.17
       '@vitest/expect': 0.32.0
       '@vitest/runner': 0.32.0
       '@vitest/snapshot': 0.32.0
@@ -8633,8 +8633,8 @@ packages:
       strip-literal: 1.0.1
       tinybench: 2.5.0
       tinypool: 0.5.0
-      vite: 4.3.9(@types/node@18.16.16)
-      vite-node: 0.32.0(@types/node@18.16.16)
+      vite: 4.3.9(@types/node@18.16.17)
+      vite-node: 0.32.0(@types/node@18.16.17)
       why-is-node-running: 2.2.2
     transitivePeerDependencies:
       - less
diff --git a/src/module.ts b/src/module.ts
index 74d6eb7..ab0ee4e 100644
--- a/src/module.ts
+++ b/src/module.ts
@@ -16,6 +16,7 @@ export interface ModuleOptions extends ImageProviders {
   screens: CreateImageOptions['screens']
   internalUrl: string
   providers: { [name: string]: InputProvider | any } & ImageProviders
+  densities: number[]
   [key: string]: any
 }
 
@@ -42,7 +43,8 @@ export default defineNuxtModule<ModuleOptions>({
     },
     internalUrl: '',
     providers: {},
-    alias: {}
+    alias: {},
+    densities: [1, 2]
   }),
   meta: {
     name: '@nuxt/image',
@@ -70,13 +72,15 @@ export default defineNuxtModule<ModuleOptions>({
     if (options.provider) {
       options[options.provider] = options[options.provider] || {}
     }
+    options.densities = options.densities || []
 
     const imageOptions: Omit<CreateImageOptions, 'providers' | 'nuxt'> = pick(options, [
       'screens',
       'presets',
       'provider',
       'domains',
-      'alias'
+      'alias',
+      'densities'
     ])
 
     const providers = await resolveProviders(nuxt, options)
diff --git a/src/runtime/components/_base.ts b/src/runtime/components/_base.ts
index 4b1eefe..149fe3f 100644
--- a/src/runtime/components/_base.ts
+++ b/src/runtime/components/_base.ts
@@ -18,6 +18,7 @@ export const baseImageProps = {
   provider: { type: String, default: undefined },
 
   sizes: { type: [Object, String] as unknown as () => string | Record<string, any>, default: undefined },
+  densities: { type: String, default: undefined },
   preload: { type: Boolean, default: undefined },
 
   // <img> attributes
diff --git a/src/runtime/components/nuxt-img.ts b/src/runtime/components/nuxt-img.ts
index d1d3dba..14d36d4 100644
--- a/src/runtime/components/nuxt-img.ts
+++ b/src/runtime/components/nuxt-img.ts
@@ -29,6 +29,7 @@ export default defineComponent({
     const sizes = computed(() => $img.getSizes(props.src!, {
       ..._base.options.value,
       sizes: props.sizes,
+      densities: props.densities,
       modifiers: {
         ..._base.modifiers.value,
         width: parseSize(props.width),
@@ -41,6 +42,16 @@ export default defineComponent({
       if (props.sizes && (!props.placeholder || placeholderLoaded.value)) {
         attrs.sizes = sizes.value.sizes
         attrs.srcset = sizes.value.srcset
+      } else if (props.densities || $img.options.densities) {
+        attrs.srcset = $img.getDensitySet(props.src!, {
+          ..._base.options.value,
+          densities: props.densities,
+          modifiers: {
+            ..._base.modifiers.value,
+            width: parseSize(props.width),
+            height: parseSize(props.height)
+          }
+        })
       }
       return attrs
     })
diff --git a/src/runtime/image.ts b/src/runtime/image.ts
index 1cd645f..fed0edf 100644
--- a/src/runtime/image.ts
+++ b/src/runtime/image.ts
@@ -2,7 +2,7 @@ import { defu } from 'defu'
 import { hasProtocol, parseURL, joinURL, withLeadingSlash } from 'ufo'
 import type { ImageOptions, ImageSizesOptions, CreateImageOptions, ResolvedImage, ImageCTX, $Img } from '../types/image'
 import { imageMeta } from './utils/meta'
-import { parseSize } from './utils'
+import { parseDensities, parseSize } from './utils'
 import { prerenderStaticImages } from './utils/prerender'
 
 export function createImage (globalOptions: CreateImageOptions) {
@@ -37,6 +37,7 @@ export function createImage (globalOptions: CreateImageOptions) {
   $img.getImage = getImage
   $img.getMeta = ((input: string, options?: ImageOptions) => getMeta(ctx, input, options)) as $Img['getMeta']
   $img.getSizes = ((input: string, options: ImageSizesOptions) => getSizes(ctx, input, options)) as $Img['getSizes']
+  $img.getDensitySet = ((input: string, options: ImageSizesOptions) => getDensitySet(ctx, input, options)) as $Img['getDensitySet']
 
   ctx.$img = $img as $Img
 
@@ -129,8 +130,11 @@ function getPreset (ctx: ImageCTX, name?: string): ImageOptions {
 function getSizes (ctx: ImageCTX, input: string, opts: ImageSizesOptions) {
   const width = parseSize(opts.modifiers?.width)
   const height = parseSize(opts.modifiers?.height)
+  const densities = opts.densities ? parseDensities(opts.densities) : ctx.options.densities
+
   const hwRatio = (width && height) ? height / width : 0
-  const variants = []
+  const sizeVariants = []
+  const srcsetVariants = []
 
   const sizes: Record<string, string> = {}
 
@@ -163,25 +167,85 @@ function getSizes (ctx: ImageCTX, input: string, opts: ImageSizesOptions) {
       _cWidth = Math.round((_cWidth / 100) * screenMaxWidth)
     }
     const _cHeight = hwRatio ? Math.round(_cWidth * hwRatio) : height
-    variants.push({
-      width: _cWidth,
+
+    // add size variant with 'media'
+    sizeVariants.push({
       size,
       screenMaxWidth,
-      media: `(max-width: ${screenMaxWidth}px)`,
-      src: ctx.$img!(input, { ...opts.modifiers, width: _cWidth, height: _cHeight }, opts)
+      media: `(max-width: ${screenMaxWidth}px)`
     })
+
+    if (densities) {
+      // add srcset variants for all densities (for current 'size' processed)
+      for (const density of densities) {
+        srcsetVariants.push({
+          width: _cWidth * density,
+          src: ctx.$img!(input, { ...opts.modifiers, width: _cWidth * density, height: _cHeight ? _cHeight * density : undefined }, opts)
+        })
+      }
+    }
+  }
+
+  sizeVariants.sort((v1, v2) => v1.screenMaxWidth - v2.screenMaxWidth)
+
+  // for last size variant, always remove `media` (convention)
+  if (sizeVariants[sizeVariants.length - 1]) {
+    sizeVariants[sizeVariants.length - 1].media = ''
+  }
+
+  // de-duplicate size variants (by key `media`)
+  let previousMedia = null
+  for (let i = sizeVariants.length - 1; i >= 0; i--) {
+    const sizeVariant = sizeVariants[i]
+    if (sizeVariant.media === previousMedia) {
+      sizeVariants.splice(i, 1)
+    }
+    previousMedia = sizeVariant.media
   }
 
-  variants.sort((v1, v2) => v1.screenMaxWidth - v2.screenMaxWidth)
+  srcsetVariants.sort((v1, v2) => v1.width - v2.width)
 
-  const defaultVar = variants[variants.length - 1]
-  if (defaultVar) {
-    defaultVar.media = ''
+  // de-duplicate srcset variants (by key `width`)
+  let previousWidth = null
+  for (let i = srcsetVariants.length - 1; i >= 0; i--) {
+    const sizeVariant = srcsetVariants[i]
+    if (sizeVariant.width === previousWidth) {
+      srcsetVariants.splice(i, 1)
+    }
+    previousWidth = sizeVariant.width
   }
 
+  // use last (:= largest) srcset variant as the image `src`
+  const defaultVar = srcsetVariants[srcsetVariants.length - 1]
+
   return {
-    sizes: variants.map(v => `${v.media ? v.media + ' ' : ''}${v.size}`).join(', '),
-    srcset: variants.map(v => `${v.src} ${v.width}w`).join(', '),
+    sizes: sizeVariants.map(v => `${v.media ? v.media + ' ' : ''}${v.size}`).join(', '),
+    srcset: srcsetVariants.map(v => `${v.src} ${v.width}w`).join(', '),
     src: defaultVar?.src
   }
 }
+
+function getDensitySet (ctx: ImageCTX, input: string, opts: ImageSizesOptions): string | undefined {
+  const densities = opts.densities ? parseDensities(opts.densities) : ctx.options.densities
+  if (densities.length === 0) {
+    return undefined
+  }
+
+  const srcsetVariants: Array<{ density: string, src: string }> = []
+
+  for (const density of densities) {
+    const modifiers = { ...opts.modifiers }
+    if (modifiers.width) {
+      modifiers.width = modifiers.width * density
+    }
+    if (modifiers.height) {
+      modifiers.height = modifiers.height * density
+    }
+    srcsetVariants.push({
+      density: `${density}x`,
+      src: ctx.$img!(input, modifiers, opts)
+    })
+  }
+
+  return srcsetVariants.map(v => `${v.src} ${v.density}`).join(', ')
+}
diff --git a/src/runtime/providers/directus.ts b/src/runtime/providers/directus.ts
index 96756fb..cdc171b 100644
--- a/src/runtime/providers/directus.ts
+++ b/src/runtime/providers/directus.ts
@@ -8,8 +8,10 @@ export const operationsGenerator = createOperationsGenerator({
 
 export const getImage: ProviderGetImage = (src, { modifiers = {}, baseURL } = {}) => {
   // Separating the transforms from the rest of the modifiers
-  const transforms = modifiers.transforms
-  if (transforms && transforms.length > 0) {
+  let transforms = modifiers.transforms
+  if (transforms && Array.isArray(transforms) && transforms.length > 0) {
+    // de-duplicate (can get multiplied when having >1 densities configured)
+    transforms = Array.from(new Set(transforms.map(obj => JSON.stringify(obj)))).map(value => JSON.parse(value))
     // We stringify and encode in URL the list of lists, then apply it back to the modifiers
     modifiers.transforms = new URLSearchParams(JSON.stringify(transforms)).toString().replace(/=+$/, '') as unknown as (string | number)[][]
   }
diff --git a/src/runtime/utils/index.ts b/src/runtime/utils/index.ts
index 7dce406..3c7ddb6 100644
--- a/src/runtime/utils/index.ts
+++ b/src/runtime/utils/index.ts
@@ -91,3 +91,14 @@ export function parseSize (input: string | number | undefined = '') {
     }
   }
 }
+
+export function parseDensities (input: string | undefined = ''): number[] {
+  if (input === undefined || !input.length) {
+    return []
+  }
+
+  const densities = input.split(' ').map(size => parseInt(size.replace('x', '')))
+
+  // de-duplicate and return
+  return densities.filter((value, index) => densities.indexOf(value) === index)
+}
diff --git a/src/types/image.ts b/src/types/image.ts
index 964a4c1..3da7328 100644
--- a/src/types/image.ts
+++ b/src/types/image.ts
@@ -9,6 +9,7 @@ export interface ImageModifiers {
 export interface ImageOptions {
   provider?: string
   preset?: string
+  densities?: string
   modifiers?: Partial<ImageModifiers>
   [key: string]: any
 }
@@ -42,6 +43,7 @@ export interface CreateImageOptions {
   screens: Record<string, number>
   alias: Record<string, string>
   domains: string[]
+  densities: number[]
 }
 
 export interface ImageInfo {


Test patch:
diff --git a/test/e2e/generate.test.ts b/test/e2e/generate.test.ts
index 1805a32..5d4a43d 100644
--- a/test/e2e/generate.test.ts
+++ b/test/e2e/generate.test.ts
@@ -34,6 +34,10 @@ describe('ipx provider', () => {
         "/_ipx/s_300x300/images/everest.jpg",
         "/_ipx/s_300x300/images/tacos.svg",
         "/_ipx/s_300x300/unsplash/photo-1606112219348-204d7d8b94ee",
+        "/_ipx/s_600x600/images/colors.jpg",
+        "/_ipx/s_600x600/images/everest.jpg",
+        "/_ipx/s_600x600/images/tacos.svg",
+        "/_ipx/s_600x600/unsplash/photo-1606112219348-204d7d8b94ee",
       ]
     `)
   })
diff --git a/test/unit/image.test.ts b/test/unit/image.test.ts
index 80d7a4b..02ea192 100644
--- a/test/unit/image.test.ts
+++ b/test/unit/image.test.ts
@@ -19,12 +19,12 @@ describe('Renders simple image', () => {
   })
 
   it('Matches snapshot', () => {
-    expect(wrapper.html()).toMatchInlineSnapshot('"<img src=\\"/_ipx/s_900x900/image.png\\" width=\\"200\\" height=\\"200\\" data-nuxt-img=\\"\\" sizes=\\"(max-width: 500px) 500px, 900px\\" srcset=\\"/_ipx/s_500x500/image.png 500w, /_ipx/s_900x900/image.png 900w\\">"')
+    expect(wrapper.html()).toMatchInlineSnapshot('"<img src=\\"/_ipx/s_1800x1800/image.png\\" width=\\"200\\" height=\\"200\\" data-nuxt-img=\\"\\" sizes=\\"(max-width: 500px) 500px, 900px\\" srcset=\\"/_ipx/s_500x500/image.png 500w, /_ipx/s_900x900/image.png 900w, /_ipx/s_1000x1000/image.png 1000w, /_ipx/s_1800x1800/image.png 1800w\\">"')
   })
 
   it('props.src is picked up by getImage()', () => {
     const domSrc = wrapper.element.getAttribute('src')
-    expect(domSrc).toMatchInlineSnapshot('"/_ipx/s_900x900/image.png"')
+    expect(domSrc).toMatchInlineSnapshot('"/_ipx/s_1800x1800/image.png"')
   })
 
   it('props.src is reactive', async () => {
@@ -33,7 +33,7 @@ describe('Renders simple image', () => {
     await nextTick()
 
     const domSrc = wrapper.find('img').element.getAttribute('src')
-    expect(domSrc).toMatchInlineSnapshot('"/_ipx/s_900x900/image.jpeg"')
+    expect(domSrc).toMatchInlineSnapshot('"/_ipx/s_1800x1800/image.jpeg"')
   })
 
   it('sizes', () => {
@@ -41,6 +41,27 @@ describe('Renders simple image', () => {
     expect(sizes).toBe('(max-width: 500px) 500px, 900px')
   })
 
+  it('applies densities', () => {
+    const img = mountImage({
+      width: 200,
+      height: 300,
+      sizes: '300:300px,400:400px',
+      densities: '1x 2x 3x',
+      src: 'image.png'
+    })
+    expect(img.html()).toMatchInlineSnapshot('"<img src=\\"/_ipx/s_1200x1800/image.png\\" width=\\"200\\" height=\\"300\\" data-nuxt-img=\\"\\" sizes=\\"(max-width: 300px) 300px, 400px\\" srcset=\\"/_ipx/s_300x450/image.png 300w, /_ipx/s_400x600/image.png 400w, /_ipx/s_600x900/image.png 600w, /_ipx/s_800x1200/image.png 800w, /_ipx/s_900x1350/image.png 900w, /_ipx/s_1200x1800/image.png 1200w\\">"')
+  })
+
+  it('de-duplicates sizes & srcset', () => {
+    const img = mountImage({
+      width: 200,
+      height: 300,
+      sizes: '200:200px,300:200px,400:400px,400:400px,500:500px,800:800px',
+      src: 'image.png'
+    })
+    expect(img.html()).toMatchInlineSnapshot('"<img src=\\"/_ipx/s_1600x2400/image.png\\" width=\\"200\\" height=\\"300\\" data-nuxt-img=\\"\\" sizes=\\"(max-width: 200px) 200px, (max-width: 300px) 200px, (max-width: 400px) 400px, (max-width: 500px) 500px, 800px\\" srcset=\\"/_ipx/s_200x300/image.png 200w, /_ipx/s_400x600/image.png 400w, /_ipx/s_500x750/image.png 500w, /_ipx/s_800x1200/image.png 800w, /_ipx/s_1000x1500/image.png 1000w, /_ipx/s_1600x2400/image.png 1600w\\">"')
+  })
+
   it('encodes characters', () => {
     const img = mountImage({
       width: 200,
@@ -48,7 +69,7 @@ describe('Renders simple image', () => {
       sizes: '200,500:500,900:900',
       src: '/汉字.png'
     })
-    expect(img.html()).toMatchInlineSnapshot('"<img src=\\"/_ipx/s_900x900/%E6%B1%89%E5%AD%97.png\\" width=\\"200\\" height=\\"200\\" data-nuxt-img=\\"\\" sizes=\\"(max-width: 500px) 500px, 900px\\" srcset=\\"/_ipx/s_500x500/%E6%B1%89%E5%AD%97.png 500w, /_ipx/s_900x900/%E6%B1%89%E5%AD%97.png 900w\\">"')
+    expect(img.html()).toMatchInlineSnapshot('"<img src=\\"/_ipx/s_1800x1800/%E6%B1%89%E5%AD%97.png\\" width=\\"200\\" height=\\"200\\" data-nuxt-img=\\"\\" sizes=\\"(max-width: 500px) 500px, 900px\\" srcset=\\"/_ipx/s_500x500/%E6%B1%89%E5%AD%97.png 500w, /_ipx/s_900x900/%E6%B1%89%E5%AD%97.png 900w, /_ipx/s_1000x1000/%E6%B1%89%E5%AD%97.png 1000w, /_ipx/s_1800x1800/%E6%B1%89%E5%AD%97.png 1800w\\">"')
   })
 
   it('correctly sets crop', () => {
@@ -58,7 +79,17 @@ describe('Renders simple image', () => {
       height: 2000,
       sizes: 'xs:100vw sm:100vw md:300px lg:350px xl:350px 2xl:350px'
     })
-    expect(img.html()).toMatchInlineSnapshot('"<img src=\\"/_ipx/s_350x700/image.png\\" width=\\"1000\\" height=\\"2000\\" data-nuxt-img=\\"\\" sizes=\\"(max-width: 320px) 100vw, (max-width: 640px) 100vw, (max-width: 768px) 300px, (max-width: 1024px) 350px, (max-width: 1280px) 350px, 350px\\" srcset=\\"/_ipx/s_320x640/image.png 320w, /_ipx/s_640x1280/image.png 640w, /_ipx/s_300x600/image.png 300w, /_ipx/s_350x700/image.png 350w, /_ipx/s_350x700/image.png 350w, /_ipx/s_350x700/image.png 350w\\">"')
+    expect(img.html()).toMatchInlineSnapshot('"<img src=\\"/_ipx/s_1280x2560/image.png\\" width=\\"1000\\" height=\\"2000\\" data-nuxt-img=\\"\\" sizes=\\"(max-width: 320px) 100vw, (max-width: 640px) 100vw, (max-width: 768px) 300px, (max-width: 1024px) 350px, (max-width: 1280px) 350px, 350px\\" srcset=\\"/_ipx/s_300x600/image.png 300w, /_ipx/s_320x640/image.png 320w, /_ipx/s_350x700/image.png 350w, /_ipx/s_600x1200/image.png 600w, /_ipx/s_640x1280/image.png 640w, /_ipx/s_700x1400/image.png 700w, /_ipx/s_1280x2560/image.png 1280w\\">"')
+  })
+
+  it('without sizes, but densities', () => {
+    const img = mountImage({
+      src: '/image.png',
+      width: 300,
+      height: 400,
+      densities: '1x 2x 3x'
+    })
+    expect(img.html()).toMatchInlineSnapshot('"<img src=\\"/_ipx/s_300x400/image.png\\" width=\\"300\\" height=\\"400\\" data-nuxt-img=\\"\\" srcset=\\"/_ipx/s_300x400/image.png 1x, /_ipx/s_600x800/image.png 2x, /_ipx/s_900x1200/image.png 3x\\">"')
   })
 })
 
diff --git a/test/unit/picture.test.ts b/test/unit/picture.test.ts
index 6be44eb..aec4e21 100644
--- a/test/unit/picture.test.ts
+++ b/test/unit/picture.test.ts
@@ -46,7 +46,7 @@ describe('Renders simple image', () => {
   it('Matches snapshot', () => {
     expect(wrapper.html()).toMatchInlineSnapshot(`
       "<picture>
-        <source type=\\"image/webp\\" sizes=\\"(max-width: 500px) 500px, 900px\\" srcset=\\"/_ipx/f_webp&s_500x500/image.png 500w, /_ipx/f_webp&s_900x900/image.png 900w\\"><img width=\\"200\\" height=\\"200\\" data-nuxt-pic=\\"\\" src=\\"/_ipx/f_png&s_900x900/image.png\\" sizes=\\"(max-width: 500px) 500px, 900px\\" srcset=\\"/_ipx/f_png&s_500x500/image.png 500w, /_ipx/f_png&s_900x900/image.png 900w\\">
+        <source type=\\"image/webp\\" sizes=\\"(max-width: 500px) 500px, 900px\\" srcset=\\"/_ipx/f_webp&s_500x500/image.png 500w, /_ipx/f_webp&s_900x900/image.png 900w, /_ipx/f_webp&s_1000x1000/image.png 1000w, /_ipx/f_webp&s_1800x1800/image.png 1800w\\"><img width=\\"200\\" height=\\"200\\" data-nuxt-pic=\\"\\" src=\\"/_ipx/f_png&s_1800x1800/image.png\\" sizes=\\"(max-width: 500px) 500px, 900px\\" srcset=\\"/_ipx/f_png&s_500x500/image.png 500w, /_ipx/f_png&s_900x900/image.png 900w, /_ipx/f_png&s_1000x1000/image.png 1000w, /_ipx/f_png&s_1800x1800/image.png 1800w\\">
       </picture>"
     `)
   })
@@ -102,7 +102,7 @@ describe('Renders simple image', () => {
     })
     expect(img.html()).toMatchInlineSnapshot(`
       "<picture>
-        <source type=\\"image/webp\\" sizes=\\"(max-width: 500px) 500px, 900px\\" srcset=\\"/_ipx/f_webp&s_500x500/%E6%B1%89%E5%AD%97.png 500w, /_ipx/f_webp&s_900x900/%E6%B1%89%E5%AD%97.png 900w\\"><img width=\\"200\\" height=\\"200\\" data-nuxt-pic=\\"\\" src=\\"/_ipx/f_png&s_900x900/%E6%B1%89%E5%AD%97.png\\" sizes=\\"(max-width: 500px) 500px, 900px\\" srcset=\\"/_ipx/f_png&s_500x500/%E6%B1%89%E5%AD%97.png 500w, /_ipx/f_png&s_900x900/%E6%B1%89%E5%AD%97.png 900w\\">
+        <source type=\\"image/webp\\" sizes=\\"(max-width: 500px) 500px, 900px\\" srcset=\\"/_ipx/f_webp&s_500x500/%E6%B1%89%E5%AD%97.png 500w, /_ipx/f_webp&s_900x900/%E6%B1%89%E5%AD%97.png 900w, /_ipx/f_webp&s_1000x1000/%E6%B1%89%E5%AD%97.png 1000w, /_ipx/f_webp&s_1800x1800/%E6%B1%89%E5%AD%97.png 1800w\\"><img width=\\"200\\" height=\\"200\\" data-nuxt-pic=\\"\\" src=\\"/_ipx/f_png&s_1800x1800/%E6%B1%89%E5%AD%97.png\\" sizes=\\"(max-width: 500px) 500px, 900px\\" srcset=\\"/_ipx/f_png&s_500x500/%E6%B1%89%E5%AD%97.png 500w, /_ipx/f_png&s_900x900/%E6%B1%89%E5%AD%97.png 900w, /_ipx/f_png&s_1000x1000/%E6%B1%89%E5%AD%97.png 1000w, /_ipx/f_png&s_1800x1800/%E6%B1%89%E5%AD%97.png 1800w\\">
       </picture>"
     `)
   })