Repository: nuxt/image
Pull request #1066 (2023-10-20 12:50:38)

feat: add `sirv` provider
This PR adds support for [Sirv](https://sirv.com/help/articles/dynamic-imaging/) image provider

codecov-commenter: ## [Codecov](https://app.codecov.io/gh/nuxt/image/pull/1066?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nuxt) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Comparison is base [(`58c5139`)](https://app.codecov.io/gh/nuxt/image/commit/58c5139106ed1552a98d318b5ca281e298c36180?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nuxt) 89.66% compared to head [(`8da8f38`)](https://app.codecov.io/gh/nuxt/image/pull/1066?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nuxt) 90.13%.

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main    #1066      +/-   ##
==========================================
+ Coverage   89.66%   90.13%   +0.46%     
==========================================
  Files          44       45       +1     
  Lines        2933     3072     +139     
  Branches      330      335       +5     
==========================================
+ Hits         2630     2769     +139     
- Misses        301      302       +1     
+ Partials        2        1       -1     
```


| [Files](https://app.codecov.io/gh/nuxt/image/pull/1066?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nuxt) | Coverage Δ | |
|---|---|---|
| [src/provider.ts](https://app.codecov.io/gh/nuxt/image/pull/1066?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nuxt#diff-c3JjL3Byb3ZpZGVyLnRz) | `97.54% <100.00%> (+0.02%)` | :arrow_up: |
| [src/runtime/providers/sirv.ts](https://app.codecov.io/gh/nuxt/image/pull/1066?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nuxt#diff-c3JjL3J1bnRpbWUvcHJvdmlkZXJzL3NpcnYudHM=) | `100.00% <100.00%> (ø)` | |
| [src/types/module.ts](https://app.codecov.io/gh/nuxt/image/pull/1066?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nuxt#diff-c3JjL3R5cGVzL21vZHVsZS50cw==) | `100.00% <100.00%> (ø)` | |


</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/nuxt/image/pull/1066?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nuxt).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=nuxt).


Patch:
diff --git a/docs/content/3.providers/sirv.md b/docs/content/3.providers/sirv.md
new file mode 100644
index 0000000..b04b2f0
--- /dev/null
+++ b/docs/content/3.providers/sirv.md
@@ -0,0 +1,261 @@
+---
+title: Sirv
+description: Nuxt Image integration with Sirv media management, transformation and delivery platform.
+links:
+  - label: Source
+    icon: i-simple-icons-github
+    to: https://github.com/nuxt/image/blob/main/src/runtime/providers/sirv.ts
+    size: xs
+---
+Integration between [Sirv](https://sirv.com/) and Nuxt image.
+
+To use Sirv provider, you need to set up your Sirv URL as the baseURL in the Nuxt Image module configuration, like this:
+
+```ts [nuxt.config.ts]
+export default defineNuxtConfig({
+  image: {
+    sirv: {
+      baseURL: 'https://youralias.sirv.com/' 
+    }
+  }
+})
+```
+
+> Get your alias from your [Sirv account details page](https://my.sirv.com/#/account/settings) or set up a custom domain ([instructions](https://sirv.com/help/articles/multiple-domains/)).
+
+## Sirv `fit` parameters
+By default, Sirv will scale the image, preserving its aspect ratio, to fit within the smallest dimension.
+Here's the map of standard values for the [fit](/usage/nuxt-img#fit) property and how they're going to be interpreted by Sirv:
+- `fill`: `ignore` 
+- `inside`: `fill`
+- `outside`: `fill`
+- `noUpscaling`: `noup`, this is the default option for Sirv image provider, so you don't need to specify it explicitly.
+
+## `format`
+If no format is specified, Sirv will deliver your images in the [optimal](https://sirv.com/help/articles/dynamic-imaging/#optimal) format by default.
+Alternatively, you can specify a custom format for the image like this:
+
+```html
+<NuxtImg
+    provider="sirv"
+    src="/example-image.jpg"
+    width="300"
+    format="webp"
+/>
+```
+## Sirv modifiers
+
+To use Sirv-specific transformations, add them in the  `modifier` prop.
+
+### `profile`
+
+Use [Sirv profiles](https://sirv.com/help/profiles) to combine multiple transformation options into a single parameter.
+For example, you can combine `canvas`, `crop` and `watermark` parameters into a single profile and use it like this:
+
+```html
+<NuxtImg
+    provider="sirv"
+    src="/example-image.jpg"
+    width="300"
+    :modifiers="{profile: 'my-profile'}
+/>
+```
+### `canvas`
+Use the `canvas` modifier to add a canvas around your image. You can also set its width, height, color, and position.
+
+### `sharpen`
+Sharpen the image using the `sharpen` modifier.
+
+```html
+<NuxtImg
+  provider="sirv"
+  src="/example-image.jpg"
+  width="300"
+  :modifiers="{sharpen: 50}"
+/>
+```
+
+### `frame`
+
+Add a frame/border to your images using the `frame` modifier. You can also set its width and color.
+
+```html
+<NuxtImg
+  provider="sirv"
+  src="/example-image.jpg"
+  width="300"
+  :modifiers="{
+    frameStyle: 'solid',
+    frameColor: '00000',
+    frameWidth: '2',
+    frameRimColor: '97A6B1',
+    frameRimWidth: '2'
+   }"
+/>
+```
+
+
+### `rotate`
+
+Use the `rotate` modifier to rotate your image. You can specify the number of degrees to rotate the image by.
+
+```html
+<NuxtImg
+  provider="sirv"
+  src="/example-image.jpg"
+  :modifiers="{rotate: 90}"
+/>
+```
+
+### Color and light options 
+Sirv has various [color manipulation options]((https://sirv.com/help/articles/dynamic-imaging/color/)) like `grayscale`, `colorize`,`colortone`,`colorLevels`, as well as [light manipulation options](https://sirv.com/help/articles/dynamic-imaging/light/) like `lightness`,
+`hue`, `saturation`, `highlights`, `shadows`, `brightness`, `exposure`, `contrast`.
+
+Here's how to convert an image to grayscale:
+
+```html
+<NuxtImg
+  provider="sirv"
+  src="/example-image.jpg"
+  width="300"
+  :modifiers="{grayscale: true}"
+/>
+```
+
+
+### Watermarks and text overlays
+Using Sirv's Nuxt Image integration, you can overlay images or text over other images for watermarking or creating a dynamic banner using custom text!
+
+#### `watermark`
+
+Add an image overlay over your existing image using the `watermark` modifier. Used mostly for watermarking, but can be useful for creating banners, OG images, and personalization. Here's an example of a single watermark:
+
+```html
+<NuxtImg provider="sirv" 
+ src="example-image.jpg" 
+ width="300" 
+ :modifiers="{
+    watermark: '/watermark-v1.png',
+    watermarkPosition: 'center',
+    watermarkWidth: '30%',
+  }" 
+/>
+```
+
+Find out more about Sirv watermarks [here](https://sirv.com/help/articles/dynamic-imaging/watermark/).
+
+#### Overlay Text
+
+You can add text overlays to your images and have full freedom over their positioning and looks.
+
+```html
+<NuxtImg provider="sirv" 
+  src="example-image.jpg" width="300"
+  :modifiers="{
+    text: 'Hello there',
+    textAlign: 'center',
+    textPositionGravity: 'south',
+    textBackgroundColor: '#ffff',
+    textSize: 60,
+    textFontFamily: 'Arial',
+    textColor: 'white',
+  }" 
+/>
+```
+More examples of text overlays can be found [here](https://sirv.com/help/articles/dynamic-imaging/text/).
+
+## List of supported transformations
+Sirv's Nuxt Image integration uses intuitive names for each transformation. If you use a property that does not match any of the following supported options, it will be added in the URL as it is.
+
+| Supported Parameter Name | Translates to Parameter    | Description                                                                                           |
+|--------------------------|----------------------------|-------------------------------------------------------------------------------------------------------|
+| width                    | w                          | Width of image.                                                                                       |
+| height                   | h                          | Height of image.                                                                                      |
+| s                        | s                          | Resize the image by its biggest side                                                                  |
+| quality                  | q                          | JPEG image quality (percentage).                                                                      |
+| fit                      | scale.option               | Image scaling options.                                                                                |
+| profile                  | profile                    | Apply a Sirv [profile](https://sirv.com/help/articles/dynamic-imaging/profiles/)                      |
+| format                   | format                     | Image format served (defaults to [optimal](https://sirv.com/help/articles/dynamic-imaging/#optimal)). |
+| webpFallback             | webp-fallback              | Image format for browsers without WebP support.                                                       |
+| subsampling              | subsampling                | Chroma subsampling to reduce JPEG file size.                                                          |
+| gifCompression           | gif.lossy                  | Apply lossy compression, to reduce GIF file size.                                                     |
+| crop                     | crop.type                  | Automatically crop to edge of image contents; point of interest; or face.                             |
+| cropAr                   | crop.aspectratio           | Aspect ratio of the crop                                                                              |
+| cw                       | cw                         | Crop the image to a specific width.                                                                   | 
+| ch                       | ch                         | Crop the image to a specific height.                                                                  |
+| cx                       | cx                         | Position to start image crop (from top).                                                              |
+| cy                       | cy                         | Position to start image crop (from left).                                                             |
+| cropPaddingX             | crop.pad.x                 | Add padding to left/right of crop area                                                                |
+| cropPaddingY             | crop.pad.y                 | Add padding to top/bottom of crop area.                                                               |
+| canvasHeight             | canvas.height              | Create a canvas around the image (height).                                                            |
+| canvasWidth              | canvas.width               | Create a canvas around the image (width).                                                             |
+| canvasAr                 | canvas.aspectratio         | Aspect ratio of the canvas from 1-99 e.g. 16:9                                                        |
+| canvasPosition           | canvas.position            | Position of the canvas behind the image.                                                              |
+| canvasBorderWidth        | canvas.border.width        | Adds additional width left and right of the canvas.                                                   |
+| canvasBorderHeight       | canvas.border.height       | Adds additional height above and below the canvas.                                                    |
+| canvasBorderColor        | canvas.border.color        | Color of the canvas border e.g. E0AA80 or red.                                                        |
+| canvasBorderOpacity      | canvas.border.opacity      | Opacity of the canvas border.                                                                         |
+| watermark                | watermark                  | Filepath of the image to be overlayed.                                                                |
+| watermarkPosition        | watermark.position         | Position of the watermark on the image.                                                               |
+| watermarkPositionGravity | watermark.position.gravity | sets the starting point for shifting the x & y values.                                                |
+| watermarkPositionX       | watermark.position.x       | Position of the watermark (from left).                                                                |
+| watermarkPositionY       | watermark.position.y       | Position of the watermark (from top).                                                                 |
+| watermarkWidth           | watermark.scale.width      | Width of watermark.                                                                                   |
+| watermarkHeight          | watermark.scale.height     | Height of watermark.                                                                                  |
+| text                     | text                       | Display text on your image.                                                                           |
+| textBase64               | text.text64                | Alternative to text parameter, with Base64 encoding                                                   |
+| textSize                 | text.size                  | Width of text area in relation to image.                                                              |
+| textAlign                | text.align                 | Align the multiline text.                                                                             |
+| textPosition             | text.position              | Location of the text on the image.                                                                    |
+| textPositionX            | text.position.x            | Location of the text (from left).                                                                     |
+| textPositionY            | text.position.y            | Location of the text (from top).                                                                      |
+| textPositionGravity      | text.position.gravity      | Master location of the text on the image.                                                             |
+| textFontSize             | text.font.size             | Fix the size of the text in px.                                                                       |
+| textFontStyle            | text.font.style            | Style of the text.                                                                                    |
+| textFontFamily           | text.font.family           | Choose a font e.g. "Open Sans".                                                                       |
+| textFontWeight           | text.font.weight           | Choose font weight (light, normal, semi-bold, bold, extra-bold).                                      |
+| textColor                | text.color                 | Text color e.g. E0AA80 or E0AA8020.                                                                   |
+| textOpacity              | text.opacity               | Text opacity.                                                                                         |
+| textOutlineWidth         | text.outline.width         | Add an outline around the text.                                                                       |
+| textoutlineColor         | text.outline.color         | Color of the text outline.                                                                            |
+| textOutlineOpacity       | text.outline.opacity       | Opacity of the text outline.                                                                          |
+| textOutlineBlur          | text.outline.blur          | Blur the edge of the text outline.                                                                    |
+| textBackgroundColor      | text.background.color      | Background color e.g. E0AA80 or E0AA8020.                                                             |
+| textBackgroundOpacity    | text.background.opacity    | Background opacity.                                                                                   |
+| sharpen                  | sharpen                    | Sharpen the image.                                                                                    |
+| blur                     | blur                       | Blur the image.                                                                                       |
+| grayscale                | grayscale                  | Make the image black & white.                                                                         |
+| colorize                 | colorize                   | Overlay a color on the image.                                                                         |
+| colorizeColor            | colorize.color             | The color of the colorize option.                                                                     |
+| colorizeOpacity          | colorize.opacity           | Opacity of the color overlay.                                                                         |
+| colortone                | colortone                  | Change the color tone of the image.                                                                   | 
+| colortoneColor           | colortone.color            | Apply a color tone to an image.                                                                       |
+| colortoneLevel           | colortone.level            | Set the level of blending with the original image.                                                    |
+| colortoneMode            | colortone.mode             | Apply the color tone to the entire image or shadows/highlights only.                                  |
+| vignette                 | vigette.value              | Adjust the depth of the vignette.                                                                     |
+| vignetteColor            | vigette.color              | Add a vignette (dark edges) around the image.                                                         |
+| lightness                | lightness                  | Change the lightness of the image.                                                                    |
+| colorlevelBlack          | colorlevel.black           | Adjust black level of image.                                                                          |
+| colorlevelWhite          | colorlevel.white           | Adjust white level of image.                                                                          |
+| histogram                | histogram                  | Display a histogram of RGB levels.                                                                    |
+| hue                      | hue                        | Change the hue of the image.                                                                          |
+| saturation               | saturation                 | Change the saturation of the image.                                                                   |
+| highlights               | highlights                 | Change the highlights of the image.                                                                   | 
+| shadows                  | shadows                    | Change the shadows of the image.                                                                      |
+| brightness               | brightness                 | Change the brightness of the image.                                                                   |
+| exposure                 | exposure                   | Change the exposure of the image.                                                                     | 
+| contrast                 | contrast                   | Change the contrast of the image.                                                                     |
+| rotate                   | rotate                     | Number of degrees to rotate the image.                                                                |
+| flip                     | flip                       | Flip image vertically (mirror).                                                                       |
+| flop                     | flop                       | Flip image horizontally (mirror).                                                                     |
+| opacity                  | opacity                    | Opacity of PNG images.                                                                                |
+| frameStyle               | frame.style                | Add a frame around the image.                                                                         |
+| frameColor               | frame.color                | Frame color e.g. E0AA80 or E0AA8020.                                                                  |
+| frameWidth               | frame.width                | Frame width.                                                                                          |
+| frameRimColor            | frame.rim.color            | Frame rim color e.g. E0AA80 or E0AA8020.                                                              |
+| frameRimWidth            | frame.rim.width            | Frame rim width.                                                                                      |
+| pdfPage                  | page                       | Page number of PDF when converted to image.                                                           |
+
+
+> Learn more about [Sirv's Image transformations](https://sirv.com/help/article/dynamic-imaging) from the official documentation.
+
diff --git a/playground/nuxt.config.ts b/playground/nuxt.config.ts
index 72def87..b7c18fb 100644
--- a/playground/nuxt.config.ts
+++ b/playground/nuxt.config.ts
@@ -92,6 +92,9 @@ export default defineNuxtConfig({
     },
     uploadcare: {
     },
+    sirv: {
+      baseURL: 'https://demo.sirv.com'
+    },
     providers: {
       custom: {
         provider: '~/providers/custom',
diff --git a/playground/providers.ts b/playground/providers.ts
index 520326c..4231c0c 100644
--- a/playground/providers.ts
+++ b/playground/providers.ts
@@ -910,5 +910,52 @@ export const providers: Provider[] = [
         modifiers: { withoutEnlargement: 'true', transforms: [['blur', 4], ['negate']] }
       }
     ]
+  },
+  // Sirv
+  {
+    name: 'sirv',
+    samples: [
+      {
+        src: '/test.png',
+        width: 750,
+        quality: 75
+      },
+      {
+        src: '/harris-large.jpg',
+        width: 500,
+        format: 'png'
+      },
+      {
+        src: '/lacoste.jpg',
+        width: 500,
+        quality: 95,
+        modifiers: { crop: 'trim' }
+      },
+      {
+        src: '/look-big.jpg',
+        height: 500,
+        fit: 'contain'
+      },
+      {
+        src: '/look-big.jpg',
+        height: 500,
+        modifiers: { crop: 'face' }
+      },
+      {
+        src: '/QW.pdf',
+        width: 500,
+        modifiers: { pdfPage: 1 }
+      },
+      {
+        src: '/look-big.jpg',
+        height: 500,
+        modifiers: { text: 'Hello', textAlign: 'center', textPositionGravity: 'south', textBackgroundColor: '#ffff', textSize: 60, textFontFamily: 'Arial', textColor: 'white' }
+      },
+      {
+        src: 't-shirt-man.jpg',
+        height: 500,
+        modifiers: { watermark: '/watermark-v1.png', watermarkPosition: 'center', watermarkWidth: '30%' }
+      }
+    ]
   }
 ]
diff --git a/src/provider.ts b/src/provider.ts
index 5c228e9..8100400 100644
--- a/src/provider.ts
+++ b/src/provider.ts
@@ -37,7 +37,8 @@ const BuiltInProviders = [
   'unsplash',
   'uploadcare',
   'vercel',
-  'wagtail'
+  'wagtail',
+  'sirv'
 ]
 
 export const providerSetup: Record<string, ProviderSetup> = {
diff --git a/src/runtime/providers/sirv.ts b/src/runtime/providers/sirv.ts
new file mode 100644
index 0000000..5b2cf48
--- /dev/null
+++ b/src/runtime/providers/sirv.ts
@@ -0,0 +1,109 @@
+import { joinURL } from 'ufo'
+import type { ProviderGetImage } from '../../types'
+import { createOperationsGenerator } from '#image'
+const deleteHash = (value: string) => value.startsWith('#') ? value.replace('#', '') : value
+const generateColorKeys = () => {
+  const keysNeedingHashDeletion = [
+    'canvasBorderColor',
+    'frameColor',
+    'frameRimColor',
+    'colorizeColor',
+    'colortoneColor',
+    'textColor',
+    'textoutlineColor',
+    'textBackgroundColor'
+  ]
+
+  return Object.fromEntries(
+    keysNeedingHashDeletion.map(key => [key, (value: string) => deleteHash(value)])
+  )
+}
+export const operationsGenerator = createOperationsGenerator({
+  keyMap: {
+    width: 'w',
+    height: 'h',
+    quality: 'q',
+    fit: 'scale.option',
+    webpFallback: 'webp-fallback',
+    gifCompression: 'gif.lossy',
+    crop: 'crop.type',
+    cropAr: 'crop.aspectratio',
+    cropPaddingX: 'crop.pad.x',
+    cropPaddingY: 'crop.pad.y',
+    canvasHeight: 'canvas.height',
+    canvasWidth: 'canvas.width',
+    canvasAr: 'canvas.aspectratio',
+    canvasPosition: 'canvas.position',
+    canvasBorderWidth: 'canvas.border.width',
+    canvasBorderHeight: 'canvas.border.height',
+    canvasBorderColor: 'canvas.border.color',
+    canvasBorderOpacity: 'canvas.border.opacity',
+    watermarkPosition: 'watermark.position',
+    watermarkPositionX: 'watermark.position.x',
+    watermarkPositionY: 'watermark.position.y',
+    watermarkPositionGravity: 'watermark.position.gravity',
+    watermarkWidth: 'watermark.scale.width',
+    watermarkHeight: 'watermark.scale.height',
+    textBase64: 'text.text64',
+    textSize: 'text.size',
+    textAlign: 'text.align',
+    textPosition: 'text.position',
+    textPositionX: 'text.position.x',
+    textPositionY: 'text.position.y',
+    textPositionGravity: 'text.position.gravity',
+    textFontSize: 'text.font.size',
+    textFontStyle: 'text.font.style',
+    textFontFamily: 'text.font.family',
+    textFontWeight: 'text.font.weight',
+    textColor: 'text.color',
+    textOpacity: 'text.opacity',
+    textOutlineWidth: 'text.outline.width',
+    textoutlineColor: 'text.outline.color',
+    textOutlineOpacity: 'text.outline.opacity',
+    textOutlineBlur: 'text.outline.blur',
+    textBackgroundColor: 'text.background.color',
+    textBackgroundOpacity: 'text.background.opacity',
+    colorizeColor: 'colorize.color',
+    colorizeOpacity: 'colorize.opacity',
+    colortoneColor: 'colortone.color',
+    colortoneLevel: 'colortone.level',
+    colortoneMode: 'colortone.mode',
+    vignette: 'vigette.value',
+    vignetteColor: 'vigette.color',
+    colorlevelBlack: 'colorlevel.black',
+    colorlevelWhite: 'colorlevel.white',
+    frameStyle: 'frame.style',
+    frameColor: 'frame.color',
+    frameWidth: 'frame.width',
+    frameRimColor: 'frame.rim.color',
+    frameRimWidth: 'frame.rim.width',
+    pdfPage: 'page'
+  },
+  valueMap: {
+    fit: {
+      contain: 'fit',
+      fill: 'ignore',
+      outside: 'fill',
+      inside: 'fill',
+      noUpscaling: 'noup'
+    },
+    crop: {
+      face: 'face',
+      poi: 'poi',
+      trim: 'trim'
+    },
+    format: {
+      jpeg: 'jpg',
+      original: 'original'
+    },
+    ...generateColorKeys()
+  },
+  joinWith: '&',
+  formatter: (key: any, value: any) => `${key}=${value}`
+})
+export const getImage: ProviderGetImage = (src, { modifiers = {}, baseURL = '/' } = {}) => {
+  const operations = operationsGenerator(modifiers)
+  return {
+    url: joinURL(baseURL, src + (operations ? ('?' + operations) : ''))
+  }
+}
diff --git a/src/types/module.ts b/src/types/module.ts
index f425bf9..dfd27be 100644
--- a/src/types/module.ts
+++ b/src/types/module.ts
@@ -42,7 +42,7 @@ export interface CloudinaryOptions {
   [key: string]: any
 }
 
-export interface UploadcareModifiers extends ImageModifiers{
+export interface UploadcareModifiers extends ImageModifiers {
   // Image Compression
   format: 'jpeg' | 'png' | 'webp' | 'auto'
   quality: 'smart' | 'smart_retina' | 'normal' | 'better' | 'best' | 'lighter' | 'lightest'
@@ -50,7 +50,7 @@ export interface UploadcareModifiers extends ImageModifiers{
   strip_meta: 'all' | 'none' | 'sensitive'
   // Image Geometry
   preview: `${number}x${number}`
-  resize: `${number}x${number}` | `${number}x`| `x${number}`
+  resize: `${number}x${number}` | `${number}x` | `x${number}`
   smart_resize: `${number}x${number}`
   crop: string | string[]
   scale_crop: string | string[]
@@ -70,6 +70,7 @@ export interface ImageProviders {
   cloudinary?: Partial<CloudinaryOptions>
   contentful?: any
   cloudimage?: any
+  sirv?: any
   fastly?: any
   glide?: any
   gumlet?: any


Test patch:
diff --git a/test/e2e/__snapshots__/no-ssr.test.ts.snap b/test/e2e/__snapshots__/no-ssr.test.ts.snap
index fc02bfe..9e120d8 100644
--- a/test/e2e/__snapshots__/no-ssr.test.ts.snap
+++ b/test/e2e/__snapshots__/no-ssr.test.ts.snap
@@ -308,6 +308,32 @@ exports[`browser (ssr: false) > sanity should render images 2`] = `
 ]
 `;
 
+exports[`browser (ssr: false) > sirv should render images 1`] = `
+[
+  "https://demo.sirv.com/test.png?w=750&q=75",
+  "https://demo.sirv.com/harris-large.jpg?w=500&format=png",
+  "https://demo.sirv.com/lacoste.jpg?crop.type=trim&w=500&q=95",
+  "https://demo.sirv.com/look-big.jpg?h=500&scale.option=fit",
+  "https://demo.sirv.com/look-big.jpg?crop.type=face&h=500",
+  "https://demo.sirv.com/QW.pdf?page=1&w=500",
+  "https://demo.sirv.com/look-big.jpg?text=Hello&text.align=center&text.position.gravity=south&text.background.color=ffff&text.size=60&text.font.family=Arial&text.color=white&h=500",
+  "https://demo.sirv.com/t-shirt-man.jpg?watermark=/watermark-v1.png&watermark.position=center&watermark.scale.width=30%&h=500",
+]
+`;
+
+exports[`browser (ssr: false) > sirv should render images 2`] = `
+[
+  "https://demo.sirv.com/test.png?w=750&q=75",
+  "https://demo.sirv.com/harris-large.jpg?w=500&format=png",
+  "https://demo.sirv.com/lacoste.jpg?crop.type=trim&w=500&q=95",
+  "https://demo.sirv.com/look-big.jpg?h=500&scale.option=fit",
+  "https://demo.sirv.com/look-big.jpg?crop.type=face&h=500",
+  "https://demo.sirv.com/QW.pdf?page=1&w=500",
+  "https://demo.sirv.com/look-big.jpg?text=Hello&text.align=center&text.position.gravity=south&text.background.color=ffff&text.size=60&text.font.family=Arial&text.color=white&h=500",
+  "https://demo.sirv.com/t-shirt-man.jpg?watermark=/watermark-v1.png&watermark.position=center&watermark.scale.width=30%&h=500",
+]
+`;
+
 exports[`browser (ssr: false) > storyblok should render images 1`] = `
 [
   "https://a.storyblok.com/f/39898/3310x2192/e4ec08624e/demo-image.jpeg",
diff --git a/test/e2e/__snapshots__/ssr.test.ts.snap b/test/e2e/__snapshots__/ssr.test.ts.snap
index 667ead2..64d85a9 100644
--- a/test/e2e/__snapshots__/ssr.test.ts.snap
+++ b/test/e2e/__snapshots__/ssr.test.ts.snap
@@ -308,6 +308,32 @@ exports[`browser (ssr: true) > sanity should render images 2`] = `
 ]
 `;
 
+exports[`browser (ssr: true) > sirv should render images 1`] = `
+[
+  "https://demo.sirv.com/test.png?w=750&q=75",
+  "https://demo.sirv.com/harris-large.jpg?w=500&format=png",
+  "https://demo.sirv.com/lacoste.jpg?crop.type=trim&w=500&q=95",
+  "https://demo.sirv.com/look-big.jpg?h=500&scale.option=fit",
+  "https://demo.sirv.com/look-big.jpg?crop.type=face&h=500",
+  "https://demo.sirv.com/QW.pdf?page=1&w=500",
+  "https://demo.sirv.com/look-big.jpg?text=Hello&text.align=center&text.position.gravity=south&text.background.color=ffff&text.size=60&text.font.family=Arial&text.color=white&h=500",
+  "https://demo.sirv.com/t-shirt-man.jpg?watermark=/watermark-v1.png&watermark.position=center&watermark.scale.width=30%&h=500",
+]
+`;
+
+exports[`browser (ssr: true) > sirv should render images 2`] = `
+[
+  "https://demo.sirv.com/test.png?w=750&q=75",
+  "https://demo.sirv.com/harris-large.jpg?w=500&format=png",
+  "https://demo.sirv.com/lacoste.jpg?crop.type=trim&w=500&q=95",
+  "https://demo.sirv.com/look-big.jpg?h=500&scale.option=fit",
+  "https://demo.sirv.com/look-big.jpg?crop.type=face&h=500",
+  "https://demo.sirv.com/QW.pdf?page=1&w=500",
+  "https://demo.sirv.com/look-big.jpg?text=Hello&text.align=center&text.position.gravity=south&text.background.color=ffff&text.size=60&text.font.family=Arial&text.color=white&h=500",
+  "https://demo.sirv.com/t-shirt-man.jpg?watermark=/watermark-v1.png&watermark.position=center&watermark.scale.width=30%&h=500",
+]
+`;
+
 exports[`browser (ssr: true) > storyblok should render images 1`] = `
 [
   "https://a.storyblok.com/f/39898/3310x2192/e4ec08624e/demo-image.jpeg",
diff --git a/test/providers.ts b/test/providers.ts
index cd098ce..4bc3735 100644
--- a/test/providers.ts
+++ b/test/providers.ts
@@ -24,7 +24,8 @@ export const images = [
     vercel: { url: '/_vercel/image?url=/test.png&w=1536&q=100' },
     wagtail: { url: '329944/original|format-webp|webpquality-70' },
     directus: { url: '/assets/1ac73658-8b62-4dea-b6da-529fbc9d01a4' },
-    uploadcare: { url: 'https://ucarecdn.com/c160afba-8b42-45a9-a46a-d393248b0072/' }
+    uploadcare: { url: 'https://ucarecdn.com/c160afba-8b42-45a9-a46a-d393248b0072/' },
+    sirv: { url: 'https://demo.sirv.com/test.png' }
   },
   {
     args: ['/test.png', { width: 200 }],
@@ -51,7 +52,8 @@ export const images = [
     vercel: { url: '/_vercel/image?url=/test.png&w=320&q=100' },
     wagtail: { url: '329944/width-200|format-webp|webpquality-70' },
     directus: { url: '/assets/1ac73658-8b62-4dea-b6da-529fbc9d01a4?width=200' },
-    uploadcare: { url: 'https://ucarecdn.com/c160afba-8b42-45a9-a46a-d393248b0072/-/resize/200x/' }
+    uploadcare: { url: 'https://ucarecdn.com/c160afba-8b42-45a9-a46a-d393248b0072/-/resize/200x/' },
+    sirv: { url: 'https://demo.sirv.com/test.png?w=200' }
   },
   {
     args: ['/test.png', { height: 200 }],
@@ -78,7 +80,8 @@ export const images = [
     vercel: { url: '/_vercel/image?url=/test.png&w=1536&q=100' },
     wagtail: { url: '329944/height-200|format-webp|webpquality-70' },
     directus: { url: '/assets/1ac73658-8b62-4dea-b6da-529fbc9d01a4?height=200' },
-    uploadcare: { url: 'https://ucarecdn.com/c160afba-8b42-45a9-a46a-d393248b0072/-/resize/x200/' }
+    uploadcare: { url: 'https://ucarecdn.com/c160afba-8b42-45a9-a46a-d393248b0072/-/resize/x200/' },
+    sirv: { url: 'https://demo.sirv.com/test.png?h=200' }
   },
   {
     args: ['/test.png', { width: 200, height: 200 }],
@@ -105,7 +108,8 @@ export const images = [
     vercel: { url: '/_vercel/image?url=/test.png&w=320&q=100' },
     wagtail: { url: '329944/fill-200x200-c0|format-webp|webpquality-70' },
     directus: { url: '/assets/1ac73658-8b62-4dea-b6da-529fbc9d01a4?width=200&height=200' },
-    uploadcare: { url: 'https://ucarecdn.com/c160afba-8b42-45a9-a46a-d393248b0072/-/resize/200x200/' }
+    uploadcare: { url: 'https://ucarecdn.com/c160afba-8b42-45a9-a46a-d393248b0072/-/resize/200x200/' },
+    sirv: { url: 'https://demo.sirv.com/test.png?w=200&h=200' }
   },
   {
     args: ['/test.png', { width: 200, height: 200, fit: 'contain' }],
@@ -132,7 +136,8 @@ export const images = [
     vercel: { url: '/_vercel/image?url=/test.png&w=320&q=100' },
     wagtail: { url: '329944/fill-200x200-c0|format-webp|webpquality-70' },
     directus: { url: '/assets/1ac73658-8b62-4dea-b6da-529fbc9d01a4?width=200&height=200&fit=contain' },
-    uploadcare: { url: 'https://ucarecdn.com/c160afba-8b42-45a9-a46a-d393248b0072/-/resize/200x200/-/stretch/off/' }
+    uploadcare: { url: 'https://ucarecdn.com/c160afba-8b42-45a9-a46a-d393248b0072/-/resize/200x200/-/stretch/off/' },
+    sirv: { url: 'https://demo.sirv.com/test.png?w=200&h=200&scale.option=fit' }
   },
   {
     args: ['/test.png', { width: 200, height: 200, fit: 'contain', format: 'jpeg' }],
@@ -159,7 +164,8 @@ export const images = [
     vercel: { url: '/_vercel/image?url=/test.png&w=320&q=100' },
     wagtail: { url: '329944/fill-200x200-c0|format-jpeg|jpegquality-70' },
     directus: { url: '/assets/1ac73658-8b62-4dea-b6da-529fbc9d01a4?width=200&height=200&fit=contain&format=jpg' },
-    uploadcare: { url: 'https://ucarecdn.com/c160afba-8b42-45a9-a46a-d393248b0072/-/format/jpeg/-/resize/200x200/-/stretch/off/' }
+    uploadcare: { url: 'https://ucarecdn.com/c160afba-8b42-45a9-a46a-d393248b0072/-/format/jpeg/-/resize/200x200/-/stretch/off/' },
+    sirv: { url: 'https://demo.sirv.com/test.png?w=200&h=200&scale.option=fit&format=jpg' }
   }
 ] as const
 
diff --git a/test/unit/providers.test.ts b/test/unit/providers.test.ts
index b39df15..f0700ab 100644
--- a/test/unit/providers.test.ts
+++ b/test/unit/providers.test.ts
@@ -30,6 +30,7 @@ import * as strapi from '#image/providers/strapi'
 import * as vercel from '#image/providers/vercel'
 import * as wagtail from '#image/providers/wagtail'
 import * as uploadcare from '#image/providers/uploadcare'
+import * as sirv from '#image/providers/sirv'
 
 const emptyContext = {
   options: {
@@ -407,7 +408,17 @@ describe('Providers', () => {
       expect(generated).toMatchObject(image.uploadcare)
     }
   })
+  it('sirv', () => {
+    const providerOptions = {
+      baseURL: 'https://demo.sirv.com'
+    }
 
+    for (const image of images) {
+      const [src, modifiers] = image.args
+      const generated = sirv.getImage(src, { modifiers, ...providerOptions }, emptyContext)
+      expect(generated).toMatchObject(image.sirv)
+    }
+  })
   it('none', () => {
     const providerOptions = {
     }