diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 939a5b6868..fa6c19bd04 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -11,7 +11,7 @@ defaults: shell: bash env: # Note: when updated, also update version in ensure-cargo-installs - viceroy_version: 0.12.2 + viceroy_version: 0.15.0 # Note: when updated, also update version in ensure-cargo-installs ! AND ! release-please.yml wasm-tools_version: 1.216.0 fastly-cli_version: 10.19.0 @@ -46,7 +46,7 @@ jobs: matrix: include: - crate: viceroy - version: 0.12.2 # Note: workflow-level env vars can't be used in matrix definitions + version: 0.15.0 # Note: workflow-level env vars can't be used in matrix definitions options: "" - crate: wasm-tools version: 1.216.0 # Note: workflow-level env vars can't be used in matrix definitions diff --git a/documentation/docs/globals/fetch.mdx b/documentation/docs/globals/fetch.mdx index b2e05a4f36..287762bfc1 100644 --- a/documentation/docs/globals/fetch.mdx +++ b/documentation/docs/globals/fetch.mdx @@ -76,6 +76,7 @@ fetch(resource, options) - *Fastly-specific* - `cacheOverride` _**Fastly-specific**_ - `cacheKey` _**Fastly-specific**_ + - `imageOptimizerOptions` _**Fastly-specific**_, see [`imageOptimizerOptions`](../../fastly:image-optimizer/imageOptimizerOptions.mdx). - `fastly` _**Fastly-specific**_ - `decompressGzip`_: boolean_ _**optional**_ - Whether to automatically gzip decompress the Response or not. diff --git a/documentation/docs/image-optimizer/Auto.mdx b/documentation/docs/image-optimizer/Auto.mdx new file mode 100644 index 0000000000..ee14fd4912 --- /dev/null +++ b/documentation/docs/image-optimizer/Auto.mdx @@ -0,0 +1,33 @@ +--- +hide_title: false +hide_table_of_contents: false +pagination_next: null +pagination_prev: null +--- + +# `Auto` + +Enumerator options for [`imageOptimizerOptions.auto`](./imageOptimizerOptions.mdx). + +## Constants + +- `AVIF` (`"avif"`) If the browser's Accept header indicates compatibility, deliver an AVIF image. +- `WEBP` (`"webp"`) If the browser's Accept header indicates compatibility, deliver a WebP image. + +## Examples + +```js +import { Auto, Region } from 'fastly:image-optimizer'; + +addEventListener("fetch", (event) => event.respondWith(handleRequest(event))); + +async function handleRequest(event) { + return await fetch('https://www.w3.org/Graphics/PNG/text2.png', { + imageOptimizerOptions: { + region: Region.UsEast, + auto: Auto.AVIF + }, + backend: 'w3' + }); +} +``` \ No newline at end of file diff --git a/documentation/docs/image-optimizer/BWAlgorithm.mdx b/documentation/docs/image-optimizer/BWAlgorithm.mdx new file mode 100644 index 0000000000..ecd80224ba --- /dev/null +++ b/documentation/docs/image-optimizer/BWAlgorithm.mdx @@ -0,0 +1,34 @@ +--- +hide_title: false +hide_table_of_contents: false +pagination_next: null +pagination_prev: null +--- + +# `BWAlgorithm` + +Enumerator options for [`imageOptimizerOptions.bw`](./imageOptimizerOptions.mdx). + +## Constants + +- `Threshold` (`"threshold"`) Uses a luminance threshold to convert the image to black and white. +- `Atkinson` (`"atkinson"`) Uses [Atkinson dithering](https://en.wikipedia.org/wiki/Atkinson_dithering) to convert the image to black and white. + + +## Examples + +```js +import { Region, BWAlgorithm } from 'fastly:image-optimizer'; + +addEventListener("fetch", (event) => event.respondWith(handleRequest(event))); + +async function handleRequest(event) { + return await fetch('https://www.w3.org/Graphics/PNG/text2.png', { + imageOptimizerOptions: { + region: Region.UsEast, + bw: BWAlgorithm.Threshold + }, + backend: 'w3' + }); +} +``` \ No newline at end of file diff --git a/documentation/docs/image-optimizer/CropMode.mdx b/documentation/docs/image-optimizer/CropMode.mdx new file mode 100644 index 0000000000..dc9da1c02c --- /dev/null +++ b/documentation/docs/image-optimizer/CropMode.mdx @@ -0,0 +1,36 @@ +--- +hide_title: false +hide_table_of_contents: false +pagination_next: null +pagination_prev: null +--- + +# `CropMode` + +Enumerator options for [`imageOptimizerOptions.crop.mode`](./imageOptimizerOptions.mdx) and `imageOptimizerOptions.precrop.mode`. + +## Constants + +- `Smart` (`"smart"`) Enables content-aware algorithms to attempt to crop the image to the desired aspect ratio while intelligently focusing on the most important visual content, including the detection of faces. +- `Safe` (`"safe"`) Allow cropping out-of-bounds regions. + +## Examples + +```js +import { CropMode, Region } from 'fastly:image-optimizer'; + +addEventListener("fetch", (event) => event.respondWith(handleRequest(event))); + +async function handleRequest(event) { + return await fetch('https://www.w3.org/Graphics/PNG/text2.png', { + imageOptimizerOptions: { + region: Region.UsEast, + crop: { + size: { ratio: { width: 4, height: 3 } }, + mode: CropMode.Smart, + } + }, + backend: 'w3' + }); +} +``` \ No newline at end of file diff --git a/documentation/docs/image-optimizer/Disable.mdx b/documentation/docs/image-optimizer/Disable.mdx new file mode 100644 index 0000000000..1c6efad7d9 --- /dev/null +++ b/documentation/docs/image-optimizer/Disable.mdx @@ -0,0 +1,33 @@ +--- +hide_title: false +hide_table_of_contents: false +pagination_next: null +pagination_prev: null +--- + +# `Disable` + +Enumerator options for [`imageOptimizerOptions.disable`](./imageOptimizerOptions.mdx). + +## Constants + +- `Upscale` (`"upscale"`) Prevent images being resized such that the output image's dimensions are larger than the source image. + +## Examples + +```js +import { Disable, Region } from 'fastly:image-optimizer'; + +addEventListener("fetch", (event) => event.respondWith(handleRequest(event))); + +async function handleRequest(event) { + return await fetch('https://www.w3.org/Graphics/PNG/text2.png', { + imageOptimizerOptions: { + region: Region.UsEast, + width: 2560, + disable: Disable.Upscale + }, + backend: 'w3' + }); +} +``` \ No newline at end of file diff --git a/documentation/docs/image-optimizer/Enable.mdx b/documentation/docs/image-optimizer/Enable.mdx new file mode 100644 index 0000000000..52ce4f510f --- /dev/null +++ b/documentation/docs/image-optimizer/Enable.mdx @@ -0,0 +1,33 @@ +--- +hide_title: false +hide_table_of_contents: false +pagination_next: null +pagination_prev: null +--- + +# `Enable` + +Enumerator options for [`imageOptimizerOptions.enable`](./imageOptimizerOptions.mdx). + +## Constants + +- `Upscale` (`"upscale"`) Allow images to be resized such that the output image's dimensions are larger than the source image. + +## Examples + +```js +import { Enable, Region } from 'fastly:image-optimizer'; + +addEventListener("fetch", (event) => event.respondWith(handleRequest(event))); + +async function handleRequest(event) { + return await fetch('https://www.w3.org/Graphics/PNG/text2.png', { + imageOptimizerOptions: { + region: Region.UsEast, + width: 2560, + enable: Enable.Upscale + }, + backend: 'w3' + }); +} +``` \ No newline at end of file diff --git a/documentation/docs/image-optimizer/Fit.mdx b/documentation/docs/image-optimizer/Fit.mdx new file mode 100644 index 0000000000..f1e8fd7174 --- /dev/null +++ b/documentation/docs/image-optimizer/Fit.mdx @@ -0,0 +1,36 @@ +--- +hide_title: false +hide_table_of_contents: false +pagination_next: null +pagination_prev: null +--- + +# `Fit` + +Enumerator options for [`imageOptimizerOptions.fit`](./imageOptimizerOptions.mdx). + +## Constants + +- `Bounds` (`"bounds"`) Resize the image to fit entirely within the specified region, making one dimension smaller if needed. +- `Cover` (`"cover"`) Resize the image to entirely cover the specified region, making one dimension larger if needed. +- `Crop` (`"crop"`) Resize and crop the image centrally to exactly fit the specified region. + +## Examples + +```js +import { Fit, Region } from 'fastly:image-optimizer'; + +addEventListener("fetch", (event) => event.respondWith(handleRequest(event))); + +async function handleRequest(event) { + return await fetch('https://www.w3.org/Graphics/PNG/text2.png', { + imageOptimizerOptions: { + region: Region.UsEast, + width: 150, + height; 150, + fit: Fit.Bounds + }, + backend: 'w3' + }); +} +``` \ No newline at end of file diff --git a/documentation/docs/image-optimizer/Format.mdx b/documentation/docs/image-optimizer/Format.mdx new file mode 100644 index 0000000000..4419da1b0a --- /dev/null +++ b/documentation/docs/image-optimizer/Format.mdx @@ -0,0 +1,46 @@ +--- +hide_title: false +hide_table_of_contents: false +pagination_next: null +pagination_prev: null +--- + +# `Format` + +Enumerator options for [`imageOptimizerOptions.format`](./imageOptimizerOptions.mdx). + +## Constants + +- `Auto` (`"auto"`) Automatically use the best format based on browser support and image/transform characteristics +- `AVIF` (`"avif"`) AVIF +- `BJPG` (`"bjpg"`) Baseline JPEG +- `GIF` (`"gif"`) Graphics Interchange Format +- `JPG` (`"jpg"`) JPEG +- `JXL` (`"jxl"`) JPEGXL +- `MP4` (`"mp4"`) MP4 (H.264) +- `PJPG` (`"pjpg"`) Progressive JPEG +- `PJXL` (`"pjxl"`) Progressive JPEGXL +- `PNG` (`"png"`) Portable Network Graphics +- `PNG8` (`"png8"`) Portable Network Graphics palette image with 256 colors and 8-bit transparency +- `SVG` (`"svg"`) Scalable Vector Graphics +- `WEBP` (`"webp"`) WebP +- `WEBPLL` (`"webpll"`) WebP (Lossless) +- `WEBPLY` (`"webply"`) WebP (Lossy) + +## Examples + +```js +import { Format, Region } from 'fastly:image-optimizer'; + +addEventListener("fetch", (event) => event.respondWith(handleRequest(event))); + +async function handleRequest(event) { + return await fetch('https://www.w3.org/Graphics/PNG/text2.png', { + imageOptimizerOptions: { + region: Region.UsEast, + format: Format.PNG + }, + backend: 'w3' + }); +} +``` \ No newline at end of file diff --git a/documentation/docs/image-optimizer/Metadata.mdx b/documentation/docs/image-optimizer/Metadata.mdx new file mode 100644 index 0000000000..402e6ea92b --- /dev/null +++ b/documentation/docs/image-optimizer/Metadata.mdx @@ -0,0 +1,34 @@ +--- +hide_title: false +hide_table_of_contents: false +pagination_next: null +pagination_prev: null +--- + +# `Metadata` + +Enumerator options for [`imageOptimizerOptions.metadata`](./imageOptimizerOptions.mdx). + +## Constants + +- `Copyright` (`"copyright"`) Preserve [copyright notice](https://www.iptc.org/std/photometadata/specification/IPTC-PhotoMetadata#copyright-notice), creator, credit line, licensor, and web statement of rights fields. +- `C2PA` (`"c2pa"`) Preserve the [C2PA manifest](https://c2pa.org/) and add any transformations performed by Fastly Image Optimizer. +- `CopyRightAndC2PA` (`"copyright,c2pa"`) Resize and crop the image centrally to exactly fit the specified region. + +## Examples + +```js +import { Metadata, Region } from 'fastly:image-optimizer'; + +addEventListener("fetch", (event) => event.respondWith(handleRequest(event))); + +async function handleRequest(event) { + return await fetch('https://www.w3.org/Graphics/PNG/text2.png', { + imageOptimizerOptions: { + region: Region.UsEast, + metadata: Metadata.Copyright + }, + backend: 'w3' + }); +} +``` \ No newline at end of file diff --git a/documentation/docs/image-optimizer/Optimize.mdx b/documentation/docs/image-optimizer/Optimize.mdx new file mode 100644 index 0000000000..4271e8190e --- /dev/null +++ b/documentation/docs/image-optimizer/Optimize.mdx @@ -0,0 +1,34 @@ +--- +hide_title: false +hide_table_of_contents: false +pagination_next: null +pagination_prev: null +--- + +# `Optimize` + +Enumerator options for [`imageOptimizerOptions.optimize`](./imageOptimizerOptions.mdx). + +## Constants + +- `Low` (`"low"`) Output image quality will be similar to the input image quality. +- `Medium` (`"medium"`) More optimization is allowed. We attempt to preserve the visual quality of the input image. +- `High` (`"high"`) Minor visual artifacts may be visible. This produces the smallest file. + +## Examples + +```js +import { Optimize, Region } from 'fastly:image-optimizer'; + +addEventListener("fetch", (event) => event.respondWith(handleRequest(event))); + +async function handleRequest(event) { + return await fetch('https://www.w3.org/Graphics/PNG/text2.png', { + imageOptimizerOptions: { + region: Region.UsEast, + optimize: Optimize.High + }, + backend: 'w3' + }); +} +``` \ No newline at end of file diff --git a/documentation/docs/image-optimizer/Orient.mdx b/documentation/docs/image-optimizer/Orient.mdx new file mode 100644 index 0000000000..642154c6db --- /dev/null +++ b/documentation/docs/image-optimizer/Orient.mdx @@ -0,0 +1,39 @@ +--- +hide_title: false +hide_table_of_contents: false +pagination_next: null +pagination_prev: null +--- + +# `Orient` + +Enumerator options for [`imageOptimizerOptions.orient`](./imageOptimizerOptions.mdx). + +## Constants + +- `Default` (`"1"`) +- `FlipHorizontal` (`"2"`) +- `FlipHorizontalAndVertical` (`"3"`) +- `FlipVertical` (`"4"`) +- `FlipHorizontalOrientLeft` (`"5"`) +- `OrientRight` (`"6"`) +- `FlipHorizontalOrientRight` (`"7"`) +- `OrientLeft` (`"8"`) + +## Examples + +```js +import { Orient, Region } from 'fastly:image-optimizer'; + +addEventListener("fetch", (event) => event.respondWith(handleRequest(event))); + +async function handleRequest(event) { + return await fetch('https://www.w3.org/Graphics/PNG/text2.png', { + imageOptimizerOptions: { + region: Region.UsEast, + orient: Orient.FlipHorizontal + }, + backend: 'w3' + }); +} +``` \ No newline at end of file diff --git a/documentation/docs/image-optimizer/Profile.mdx b/documentation/docs/image-optimizer/Profile.mdx new file mode 100644 index 0000000000..193382ae54 --- /dev/null +++ b/documentation/docs/image-optimizer/Profile.mdx @@ -0,0 +1,34 @@ +--- +hide_title: false +hide_table_of_contents: false +pagination_next: null +pagination_prev: null +--- + +# `Profile` + +Enumerator options for [`imageOptimizerOptions.profile`](./imageOptimizerOptions.mdx). + +## Constants + +- `Baseline` (`"baseline"`) The profile recommended for video conferencing and mobile applications. (Default) +- `Main` (`"main"`) The profile recommended for standard-definition broadcasts. +- `High` (`"high"`) The profile recommended for high-definition broadcasts. + +## Examples + +```js +import { Profile, Region } from 'fastly:image-optimizer'; + +addEventListener("fetch", (event) => event.respondWith(handleRequest(event))); + +async function handleRequest(event) { + return await fetch('https://www.w3.org/Graphics/PNG/text2.png', { + imageOptimizerOptions: { + region: Region.UsEast, + profile: Profile.Main + }, + backend: 'w3' + }); +} +``` \ No newline at end of file diff --git a/documentation/docs/image-optimizer/Region.mdx b/documentation/docs/image-optimizer/Region.mdx new file mode 100644 index 0000000000..3d046e9607 --- /dev/null +++ b/documentation/docs/image-optimizer/Region.mdx @@ -0,0 +1,38 @@ +--- +hide_title: false +hide_table_of_contents: false +pagination_next: null +pagination_prev: null +--- + +# `Region` + +Enumerator options for [`imageOptimizerOptions.region`](./imageOptimizerOptions.mdx). + +## Constants + +- `UsEast` (`"us_east"`) +- `UsCentral` (`"us_central"`) +- `UsWest` (`"us_west"`) +- `EuCentral` (`"eu_central"`) +- `EuWest` (`"eu_west"`) +- `Asia` (`"asia"`) +- `Australia` (`"australia"`) + + +## Examples + +```js +import { Region } from 'fastly:image-optimizer'; + +addEventListener("fetch", (event) => event.respondWith(handleRequest(event))); + +async function handleRequest(event) { + return await fetch('https://www.w3.org/Graphics/PNG/text2.png', { + imageOptimizerOptions: { + region: Region.UsEast + }, + backend: 'w3' + }); +} +``` \ No newline at end of file diff --git a/documentation/docs/image-optimizer/ResizeFilter.mdx b/documentation/docs/image-optimizer/ResizeFilter.mdx new file mode 100644 index 0000000000..cd04a4b287 --- /dev/null +++ b/documentation/docs/image-optimizer/ResizeFilter.mdx @@ -0,0 +1,40 @@ +--- +hide_title: false +hide_table_of_contents: false +pagination_next: null +pagination_prev: null +--- + +# `ResizeFilter` + +Enumerator options for [`imageOptimizerOptions.resizeFilter`](./imageOptimizerOptions.mdx). + +## Constants + +- `Nearest` (`"nearest"`) Uses the value of nearby translated pixel values. +- `Bilinear` (`"bilinear"`) Uses an average of a 2x2 environment of pixels. +- `Linear` (`"linear"`) Same as `Bilenear`. +- `Bicubic` (`"bicubic"`) Uses an average of a 4x4 environment of pixels, weighing the innermost pixels higher. +- `Cubic` (`"cubic"`) Same as `Bicubic`. +- `Lanczos2` (`"lanczos2"`) Uses the Lanczos filter to increase the ability to detect edges and linear features within an image and uses sinc resampling to provide the best possible reconstruction. +- `Lanczos3` (`"lanczos3"`) Lanczos3 uses a better approximation of the sinc resampling function. (Default) +- `Lanczos` (`"lanczos"`) Same as `Lanczos3`. + +## Examples + +```js +import { ResizeFilter, Region } from 'fastly:image-optimizer'; + +addEventListener("fetch", (event) => event.respondWith(handleRequest(event))); + +async function handleRequest(event) { + return await fetch('https://www.w3.org/Graphics/PNG/text2.png', { + imageOptimizerOptions: { + region: Region.UsEast, + width: 2560, + resizeFilter: ResizeFilter.Linear + }, + backend: 'w3' + }); +} +``` \ No newline at end of file diff --git a/documentation/docs/image-optimizer/imageOptimizerOptions.mdx b/documentation/docs/image-optimizer/imageOptimizerOptions.mdx new file mode 100644 index 0000000000..8675d5076a --- /dev/null +++ b/documentation/docs/image-optimizer/imageOptimizerOptions.mdx @@ -0,0 +1,135 @@ +--- +hide_title: false +hide_table_of_contents: false +pagination_next: null +pagination_prev: null +--- + +# `imageOptimizerOptions` + +Options specified in the [`Request`](../globals/Request/Request.mdx) constructor for running the [Fastly Image Optimizer](https://docs.fastly.com/products/image-optimizer). More detailed documentation on all Image Optimizer options is available in the [Image Optimizer reference docs](https://www.fastly.com/documentation/reference/io/). + +## Parameters + +All parameters other than `region` are optional. + +- `region`: _[`Region`](./Region.mdx)_ Where image optimizations should occur. +- `auto`: _[`Auto`](./Auto.mdx)_ Enable optimization features automatically. +- `bgColor`: _[`Color`](#color)_ Set the background color of an image. +- `blur`: _`number` (0.5-1000) or [`Percentage`](#percentage)_ Set the blurriness of the output image. +- `brightness`: _`number` (-100-100)_ Set the brightness of the output image. +- `bw`: _[`BWAlgorithm`](./BWAlgorithm.mdx)_ Convert an image to black and white. +- `canvas`: _`Object`_ Increase the size of the canvas around an image. + - `size`: _[`Size`](#size)_ + - `position` (optional): _[`Position`](#position)_ +- `contrast`: _`number` (-100-100)_ Set the contrast of the output image. +- `crop`: _`Object`_ Remove pixels from an image. + - `size`: _[`Size`](#size)_ + - `position` (optional): _[`Position`](#position)_ + - `mode` (optional): _[`CropMode`](./CropMode.mdx)_ +- `disable`: _[`Disable`](./Disable.mdx)_ Disable functionality that is enabled by default. +- `dpr`: `number` Ratio between physical pixels and logical pixels. +- `enable`: _[`Enable`](./Enable.mdx)_ Enable functionality that is disabled by default. +- `fit`: _[`Fit`](./Fit.mdx)_ Set how the image will fit within the size bounds provided. +- `format`: _[`Format`](./Format.mdx)_ Specify the output format to convert the image to. +- `frame`: _`number` (must have the value 1)_ Extract the first frame from an animated image sequence. +- `height`: _`integer` (number of pixels) or [`Percentage`](#percentage)_ Resize the height of the image. +- `level`: _`String` containing one of the [allowed values](https://www.fastly.com/documentation/reference/io/level/#allowed-values)_ Specify the level constraints when converting to video. +- `metadata`: _[`Metadata`](./Metadata.mdx)_ Control which metadata fields are preserved during transformation. +- `optimize`: _[`Optimize`](./Optimize.mdx)_ Automatically apply optimal quality compression. +- `orient`: _[`Orient`](./Orient.mdx)_ Change the cardinal orientation of the image. +- `pad`: _[`Sides`](#sides)_ Add pixels to the edge of an image. +- `precrop`: _`Object`_ Remove pixels from an image before any other transformations occur. + - `size`: _[`Size`](#size)_ + - `position`: _[`Position`](#position)_ + - `mode`: _[`CropMode`](./CropMode.mdx)_ +- `profile`: _[`Profile`](./Profile.mdx)_ Specify the profile class of application when converting to video. +- `quality`: _`integer` (1-100)_ Optimize the image to the given compression level for lossy file formatted images. +- `resizeFilter`: _[`ResizeFilter`](./ResizeFilter.mdx)_ Specify the resize filter used when resizing images. +- `saturation`: _`number` (-100-100)_ Set the saturation of the output image. +- `sharpen`: _`Object`_ Set the sharpness of the output image. + - `amount`: _`number` (0-10)_ + - `radius`: _`number` (0.5-1000)_ + - `threshold`: _`integer` (0-255)_ +- `trim`: _[`Sides`](#sides)_ Remove pixels from the edge of an image. +- `viewbox`: _`number` (must have the value 1)_ Remove explicit width and height properties in SVG output. +- `width`: _`integer` (number of pixels) or [`Percentage`](#percentage)_ Resize the width of the image. + +## Types + +### Color + +Either: + +- a 3 or 6 character hexadecimal string +- an `Object` containing: + - `r`: _`integer` (0-255)_ Red component + - `g`: _`integer` (0-255)_ Green component + - `b`: _`integer` (0-255)_ Blue component + - `a` (optional): _`number` (0.0-1.0)_ Alpha component + +### Percentage + +A `String` containing a number suffixed with a percent sign (%). + +### Position + +An `Object` containing: + +- Exactly one of: + - `x`: _`integer` (number of pixels) or [`Percentage`](#percentage)_ + - `offsetX`: _`number` (interpreted as a percentage)_ +- Exactly one of: + - `y`: _`integer` (number of pixels) or [`Percentage`](#percentage)_ + - `offsetY`: _`number` (interpreted as a percentage)_ + +### Sides + +An `Object` containing `top`, `bottom`, `left`, and `right`, all of which are either an `integer` or [`Percentage`](#percentage). + +### Size + +An `Object` containing either: + +- `absolute`: _`Object`_ + - `width`: _`integer` (number of pixels) or [`Percentage`](#percentage)_ + - `height`: _`integer` (number of pixels) or [`Percentage`](#percentage)_ +- `ratio`: _`Object`_ + - `width`: _`number`_ + - `height`: _`number`_ + +## Examples + +```js +import { Format, Orient, CropMode, Region } from 'fastly:image-optimizer'; + +addEventListener("fetch", (event) => event.respondWith(handleRequest(event))); + +async function handleRequest(event) { + return await fetch('https://www.w3.org/Graphics/PNG/text2.png', { + imageOptimizerOptions: { + region: Region.UsEast, + format: Format.PNG, + bgColor: { + 'r': 100, + 'g': 255, + 'b': 9, + 'a': 0.5 + }, + blur: '1%', + brightness: -20, + contrast: -20, + height: 600, + level: '4.0', + orient: Orient.FlipVertical, + saturation: 80, + sharpen: { 'amount': 5, 'radius': 6, 'threshold': 44 }, + canvas: { 'size': { 'absolute': { 'width': 400, 'height': 400 } } }, + crop: { size: { absolute: { width: 200, height: 200 }, mode: CropMode.Safe } }, + trim: { top: 10, left: 10, right: 10, bottom: 10 }, + pad: { top: 30, left: 30, right: "1%", bottom: 30 } + }, + backend: 'w3' + }); +} +``` \ No newline at end of file diff --git a/documentation/rename-docs.mjs b/documentation/rename-docs.mjs index ed2951a3d0..19da926285 100644 --- a/documentation/rename-docs.mjs +++ b/documentation/rename-docs.mjs @@ -26,7 +26,8 @@ const subsystems = [ 'logger', 'object-store', 'secret-store', - 'html-rewriter' + 'html-rewriter', + 'image-optimizer' ]; const files = readdirSync('docs'); diff --git a/integration-tests/js-compute/fixtures/app/src/image-optimizer.js b/integration-tests/js-compute/fixtures/app/src/image-optimizer.js new file mode 100644 index 0000000000..b8e611ecda --- /dev/null +++ b/integration-tests/js-compute/fixtures/app/src/image-optimizer.js @@ -0,0 +1,1116 @@ +/* eslint-env serviceworker */ + +import { routes } from './routes.js'; +import { + Region, + Auto, + BWAlgorithm, + CropMode, + Disable, + Enable, + Fit, + Metadata, + Optimize, + Orient, + Profile, + ResizeFilter, + optionsToQueryString, +} from 'fastly:image-optimizer'; +import { assert, assertThrows } from './assertions.js'; + +// Enums +routes.set('/image-optimizer/options/region', () => { + assert(optionsToQueryString({ region: Region.UsEast }), 'region=us_east'); + assert(optionsToQueryString({ region: Region.Asia }), 'region=asia'); + assertThrows(() => optionsToQueryString({ region: 'invalid' }), TypeError); + assertThrows(() => optionsToQueryString({}), TypeError); +}); +routes.set('/image-optimizer/options/auto', () => { + assert( + optionsToQueryString({ region: Region.Asia, auto: Auto.AVIF }), + 'region=asia&auto=avif', + ); + assert( + optionsToQueryString({ region: Region.Asia, auto: Auto.WEBP }), + 'region=asia&auto=webp', + ); + assertThrows( + () => optionsToQueryString({ region: Region.Asia, auto: 'invalid' }), + TypeError, + ); +}); +routes.set('/image-optimizer/options/bw', () => { + assert( + optionsToQueryString({ + region: Region.EuCentral, + bw: BWAlgorithm.Threshold, + }), + 'region=eu_central&bw=threshold', + ); + assert( + optionsToQueryString({ + region: Region.EuCentral, + bw: BWAlgorithm.Atkinson, + }), + 'region=eu_central&bw=atkinson', + ); + assertThrows( + () => optionsToQueryString({ region: Region.EuCentral, bw: 'invalid' }), + TypeError, + ); +}); +routes.set('/image-optimizer/options/crop-mode', () => { + const qs = optionsToQueryString({ + region: Region.UsWest, + crop: { + size: { absolute: { width: 100, height: 100 } }, + mode: CropMode.Smart, + }, + }); + assert(qs.includes('smart'), true); + assertThrows( + () => + optionsToQueryString({ + region: Region.UsWest, + crop: { size: { absolute: { width: 100, height: 100 } }, mode: 'bad' }, + }), + TypeError, + ); +}); +routes.set('/image-optimizer/options/disable', () => { + assert( + optionsToQueryString({ + region: Region.Australia, + disable: Disable.Upscale, + }), + 'region=australia&disable=upscale', + ); + assertThrows(() => + optionsToQueryString({ region: Region.Australia, disable: 'invalid' }), + ); +}); +routes.set('/image-optimizer/options/enable', () => { + assert( + optionsToQueryString({ region: Region.Australia, enable: Enable.Upscale }), + 'region=australia&enable=upscale', + ); + assertThrows(() => + optionsToQueryString({ region: Region.Australia, enable: 'invalid' }), + ); +}); +routes.set('/image-optimizer/options/fit', () => { + assert( + optionsToQueryString({ region: Region.Australia, fit: Fit.Crop }), + 'region=australia&fit=crop', + ); + assert( + optionsToQueryString({ region: Region.Australia, fit: Fit.Cover }), + 'region=australia&fit=cover', + ); + assertThrows(() => + optionsToQueryString({ region: Region.Australia, fit: 'invalid' }), + ); +}); +routes.set('/image-optimizer/options/metadata', () => { + assert( + optionsToQueryString({ region: Region.Australia, metadata: Metadata.C2PA }), + 'region=australia&metadata=c2pa', + ); + assert( + optionsToQueryString({ + region: Region.Australia, + metadata: Metadata.CopyrightAndC2PA, + }), + 'region=australia&metadata=copyright,c2pa', + ); + assertThrows(() => + optionsToQueryString({ region: Region.Australia, metadata: 'invalid' }), + ); +}); +routes.set('/image-optimizer/options/optimize', () => { + assert( + optionsToQueryString({ region: Region.Australia, optimize: Optimize.Low }), + 'region=australia&optimize=low', + ); + assert( + optionsToQueryString({ region: Region.Australia, optimize: Optimize.High }), + 'region=australia&optimize=high', + ); + assertThrows(() => + optionsToQueryString({ region: Region.Australia, optimize: 'invalid' }), + ); +}); +routes.set('/image-optimizer/options/orient', () => { + assert( + optionsToQueryString({ region: Region.Australia, orient: Orient.Default }), + 'region=australia&orient=1', + ); + assert( + optionsToQueryString({ + region: Region.Australia, + orient: Orient.FlipHorizontalOrientRight, + }), + 'region=australia&orient=7', + ); + assertThrows(() => + optionsToQueryString({ region: Region.Australia, orient: 'invalid' }), + ); +}); +routes.set('/image-optimizer/options/profile', () => { + assert( + optionsToQueryString({ + region: Region.Australia, + profile: Profile.Baseline, + }), + 'region=australia&profile=baseline', + ); + assert( + optionsToQueryString({ region: Region.Australia, profile: Profile.Main }), + 'region=australia&profile=main', + ); + assertThrows(() => + optionsToQueryString({ region: Region.Australia, profile: 'invalid' }), + ); +}); +routes.set('/image-optimizer/options/resizeFilter', () => { + assert( + optionsToQueryString({ + region: Region.Australia, + resizeFilter: ResizeFilter.Bicubic, + }), + 'region=australia&resize-filter=bicubic', + ); + assert( + optionsToQueryString({ + region: Region.Australia, + resizeFilter: ResizeFilter.Lanczos2, + }), + 'region=australia&resize-filter=lanczos2', + ); + assertThrows(() => + optionsToQueryString({ region: Region.Australia, resizeFilter: 'invalid' }), + ); +}); + +// Other options +routes.set('/image-optimizer/options/bgColor', () => { + // Hex strings + assert( + optionsToQueryString({ + region: Region.Asia, + bgColor: '123456', + }), + 'region=asia&bg-color=123456', + ); + assert( + optionsToQueryString({ + region: Region.Asia, + bgColor: 'a2345e', + }), + 'region=asia&bg-color=a2345e', + ); + assert( + optionsToQueryString({ + region: Region.Asia, + bgColor: '123', + }), + 'region=asia&bg-color=123', + ); + assert( + optionsToQueryString({ + region: Region.Asia, + bgColor: 'a2e', + }), + 'region=asia&bg-color=a2e', + ); + assertThrows( + () => + optionsToQueryString({ + region: Region.Asia, + bgColor: '12', + }), + TypeError, + ); + assertThrows( + () => + optionsToQueryString({ + region: Region.Asia, + bgColor: '12j', + }), + TypeError, + ); + + // RGB(A) + assert( + optionsToQueryString({ + region: Region.Asia, + bgColor: { + r: 255, + g: 0, + b: 128, + }, + }), + 'region=asia&bg-color=255,0,128', + ); + assert( + optionsToQueryString({ + region: Region.Asia, + bgColor: { + r: 255, + g: 0, + b: 128, + a: 0.5, + }, + }), + 'region=asia&bg-color=255,0,128,0.500000', + ); + assertThrows( + () => + optionsToQueryString({ + region: Region.Asia, + bgColor: { + r: 12, + b: 12, + }, + }), + TypeError, + ); + assertThrows( + () => + optionsToQueryString({ + region: Region.Asia, + bgColor: { + r: 12, + b: 1212, + g: 12, + }, + }), + TypeError, + ); + + assertThrows( + () => + optionsToQueryString({ + region: Region.Asia, + bgColor: 123123, + }), + TypeError, + ); +}); + +routes.set('/image-optimizer/options/blur', () => { + assert( + optionsToQueryString({ region: Region.Asia, blur: 0.5 }), + 'region=asia&blur=0.500000', + ); + assert( + optionsToQueryString({ region: Region.Asia, blur: 1000 }), + 'region=asia&blur=1000.000000', + ); + assertThrows( + () => optionsToQueryString({ region: Region.Asia, blur: 1001 }), + TypeError, + ); + assertThrows( + () => optionsToQueryString({ region: Region.Asia, blur: 0.4 }), + TypeError, + ); + + assert( + optionsToQueryString({ region: Region.Asia, blur: '10%' }), + 'region=asia&blur=10.000000p', + ); + assert( + optionsToQueryString({ region: Region.Asia, blur: '0.5%' }), + 'region=asia&blur=0.500000p', + ); +}); +routes.set('/image-optimizer/options/brightness', () => { + assert( + optionsToQueryString({ region: Region.Asia, brightness: 0.5 }), + 'region=asia&brightness=0.500000', + ); + assert( + optionsToQueryString({ region: Region.Asia, brightness: 100 }), + 'region=asia&brightness=100.000000', + ); + assert( + optionsToQueryString({ region: Region.Asia, brightness: -100 }), + 'region=asia&brightness=-100.000000', + ); + assertThrows( + () => optionsToQueryString({ region: Region.Asia, brightness: 1001 }), + TypeError, + ); + assertThrows( + () => optionsToQueryString({ region: Region.Asia, brightness: -101 }), + TypeError, + ); +}); +routes.set('/image-optimizer/options/canvas', () => { + assert( + optionsToQueryString({ + region: Region.Asia, + canvas: { + size: { + absolute: { + width: 100, + height: '10%', + }, + }, + }, + }), + 'region=asia&canvas=100,10.000000p', + ); + assert( + optionsToQueryString({ + region: Region.Asia, + canvas: { + size: { + ratio: { + width: 4, + height: 3, + }, + }, + }, + }), + 'region=asia&canvas=4.000000:3.000000', + ); + assert( + optionsToQueryString({ + region: Region.Asia, + canvas: { + size: { + absolute: { + width: 100, + height: '10%', + }, + }, + position: { + x: 10, + offsetY: 10, + }, + }, + }), + 'region=asia&canvas=100,10.000000p,x10,offset-y10.000000', + ); + assert( + optionsToQueryString({ + region: Region.Asia, + canvas: { + size: { + ratio: { + width: 4, + height: 3, + }, + }, + position: { + offsetX: 10, + y: '10%', + }, + }, + }), + 'region=asia&canvas=4.000000:3.000000,offset-x10.000000,y10.000000p', + ); + assertThrows( + () => + optionsToQueryString({ + region: Region.Asia, + canvas: { + position: { + offsetX: 10, + y: '10%', + }, + }, + }), + TypeError, + ); + assertThrows( + () => + optionsToQueryString({ + region: Region.Asia, + canvas: { + size: { + ratio: { + width: '4%', + height: 3, + }, + }, + position: { + offsetX: 10, + y: '10%', + }, + }, + }), + TypeError, + ); + assertThrows( + () => + optionsToQueryString({ + region: Region.Asia, + canvas: { + size: { + ratio: { + width: '4%', + height: 3, + }, + }, + position: { + offsetX: 10, + x: 100, + y: '10%', + }, + }, + }), + TypeError, + ); + assertThrows( + () => + optionsToQueryString({ + region: Region.Asia, + canvas: { + size: { + ratio: { + width: '4%', + height: 3, + }, + }, + position: { + y: '10%', + }, + }, + }), + TypeError, + ); +}); +routes.set('/image-optimizer/options/crop', () => { + assert( + optionsToQueryString({ + region: Region.Asia, + crop: { + size: { + absolute: { + width: 100, + height: '10%', + }, + }, + }, + }), + 'region=asia&crop=100,10.000000p', + ); + assert( + optionsToQueryString({ + region: Region.Asia, + crop: { + size: { + ratio: { + width: 4, + height: 3, + }, + }, + }, + }), + 'region=asia&crop=4.000000:3.000000', + ); + assert( + optionsToQueryString({ + region: Region.Asia, + crop: { + size: { + absolute: { + width: 100, + height: '10%', + }, + }, + position: { + x: 10, + offsetY: 10, + }, + }, + }), + 'region=asia&crop=100,10.000000p,x10,offset-y10.000000', + ); + assert( + optionsToQueryString({ + region: Region.Asia, + crop: { + size: { + ratio: { + width: 4, + height: 3, + }, + }, + position: { + offsetX: 10, + y: '10%', + }, + }, + }), + 'region=asia&crop=4.000000:3.000000,offset-x10.000000,y10.000000p', + ); + assert( + optionsToQueryString({ + region: Region.Asia, + crop: { + size: { + ratio: { + width: 4, + height: 3, + }, + }, + position: { + offsetX: 10, + y: '10%', + }, + mode: CropMode.Safe, + }, + }), + 'region=asia&crop=4.000000:3.000000,offset-x10.000000,y10.000000p,safe', + ); + assertThrows( + () => + optionsToQueryString({ + region: Region.Asia, + crop: { + position: { + offsetX: 10, + y: '10%', + }, + }, + }), + TypeError, + ); + assertThrows( + () => + optionsToQueryString({ + region: Region.Asia, + crop: { + size: { + ratio: { + width: '4%', + height: 3, + }, + }, + position: { + offsetX: 10, + y: '10%', + }, + }, + }), + TypeError, + ); + assertThrows( + () => + optionsToQueryString({ + region: Region.Asia, + crop: { + size: { + ratio: { + width: '4%', + height: 3, + }, + }, + position: { + offsetX: 10, + x: 100, + y: '10%', + }, + }, + }), + TypeError, + ); + assertThrows( + () => + optionsToQueryString({ + region: Region.Asia, + crop: { + size: { + ratio: { + width: '4%', + height: 3, + }, + }, + position: { + y: '10%', + }, + }, + }), + TypeError, + ); + assertThrows( + () => + optionsToQueryString({ + region: Region.Asia, + crop: { + size: { + ratio: { + width: 4, + height: 3, + }, + }, + position: { + offsetX: 10, + y: '10%', + }, + mode: 'invalid', + }, + }), + TypeError, + ); +}); +routes.set('/image-optimizer/options/precrop', () => { + assert( + optionsToQueryString({ + region: Region.Asia, + precrop: { + size: { + absolute: { + width: 100, + height: '10%', + }, + }, + }, + }), + 'region=asia&precrop=100,10.000000p', + ); + assert( + optionsToQueryString({ + region: Region.Asia, + precrop: { + size: { + ratio: { + width: 4, + height: 3, + }, + }, + }, + }), + 'region=asia&precrop=4.000000:3.000000', + ); + assert( + optionsToQueryString({ + region: Region.Asia, + precrop: { + size: { + absolute: { + width: 100, + height: '10%', + }, + }, + position: { + x: 10, + offsetY: 10, + }, + }, + }), + 'region=asia&precrop=100,10.000000p,x10,offset-y10.000000', + ); + assert( + optionsToQueryString({ + region: Region.Asia, + precrop: { + size: { + ratio: { + width: 4, + height: 3, + }, + }, + position: { + offsetX: 10, + y: '10%', + }, + }, + }), + 'region=asia&precrop=4.000000:3.000000,offset-x10.000000,y10.000000p', + ); + assert( + optionsToQueryString({ + region: Region.Asia, + precrop: { + size: { + ratio: { + width: 4, + height: 3, + }, + }, + position: { + offsetX: 10, + y: '10%', + }, + mode: CropMode.Safe, + }, + }), + 'region=asia&precrop=4.000000:3.000000,offset-x10.000000,y10.000000p,safe', + ); + assertThrows( + () => + optionsToQueryString({ + region: Region.Asia, + precrop: { + position: { + offsetX: 10, + y: '10%', + }, + }, + }), + TypeError, + ); + assertThrows( + () => + optionsToQueryString({ + region: Region.Asia, + precrop: { + size: { + ratio: { + width: '4%', + height: 3, + }, + }, + position: { + offsetX: 10, + y: '10%', + }, + }, + }), + TypeError, + ); + assertThrows( + () => + optionsToQueryString({ + region: Region.Asia, + precrop: { + size: { + ratio: { + width: '4%', + height: 3, + }, + }, + position: { + offsetX: 10, + x: 100, + y: '10%', + }, + }, + }), + TypeError, + ); + assertThrows( + () => + optionsToQueryString({ + region: Region.Asia, + precrop: { + size: { + ratio: { + width: '4%', + height: 3, + }, + }, + position: { + y: '10%', + }, + }, + }), + TypeError, + ); + assertThrows( + () => + optionsToQueryString({ + region: Region.Asia, + precrop: { + size: { + ratio: { + width: 4, + height: 3, + }, + }, + position: { + offsetX: 10, + y: '10%', + }, + mode: 'invalid', + }, + }), + TypeError, + ); +}); +routes.set('/image-optimizer/options/dpr', () => { + assert( + optionsToQueryString({ region: Region.Asia, dpr: 1.5 }), + 'region=asia&dpr=1.500000', + ); + assert( + optionsToQueryString({ region: Region.Asia, dpr: 10 }), + 'region=asia&dpr=10.000000', + ); + assertThrows( + () => optionsToQueryString({ region: Region.Asia, dpr: '1001' }), + TypeError, + ); + assertThrows( + () => optionsToQueryString({ region: Region.Asia, dpr: 1001 }), + TypeError, + ); + assertThrows( + () => optionsToQueryString({ region: Region.Asia, dpr: 0.5 }), + TypeError, + ); +}); +routes.set('/image-optimizer/options/frame', () => { + assert( + optionsToQueryString({ region: Region.Asia, frame: 1 }), + 'region=asia&frame=1', + ); + assertThrows( + () => optionsToQueryString({ region: Region.Asia, frame: 2 }), + TypeError, + ); +}); +routes.set('/image-optimizer/options/height', () => { + assert( + optionsToQueryString({ region: Region.Asia, height: 1000 }), + 'region=asia&height=1000', + ); + assert( + optionsToQueryString({ region: Region.Asia, height: '10%' }), + 'region=asia&height=10.000000p', + ); + assertThrows( + () => optionsToQueryString({ region: Region.Asia, height: '1001' }), + TypeError, + ); + assertThrows( + () => optionsToQueryString({ region: Region.Asia, height: 100.5 }), + TypeError, + ); +}); +routes.set('/image-optimizer/options/level', () => { + assert( + optionsToQueryString({ region: Region.Asia, level: '1.1' }), + 'region=asia&level=1.1', + ); + assert( + optionsToQueryString({ region: Region.Asia, level: '5.1' }), + 'region=asia&level=5.1', + ); + assertThrows( + () => optionsToQueryString({ region: Region.Asia, level: 5.1 }), + TypeError, + ); + assertThrows( + () => optionsToQueryString({ region: Region.Asia, level: '7.1' }), + TypeError, + ); +}); +routes.set('/image-optimizer/options/pad', () => { + assert( + optionsToQueryString({ + region: Region.Asia, + pad: { + top: 10, + bottom: 20, + left: '10%', + right: '20%', + }, + }), + 'region=asia&pad=10,20.000000p,20,10.000000p', + ); + assertThrows( + () => + optionsToQueryString({ + region: Region.Asia, + pad: { + top: 10, + bottom: 20, + left: '10', + right: '20%', + }, + }), + TypeError, + ); + assertThrows( + () => + optionsToQueryString({ + region: Region.Asia, + pad: { + top: 10, + left: '10', + right: '20%', + }, + }), + TypeError, + ); +}); +routes.set('/image-optimizer/options/quality', () => { + assert( + optionsToQueryString({ region: Region.Asia, quality: 1 }), + 'region=asia&quality=1', + ); + assert( + optionsToQueryString({ region: Region.Asia, quality: 100 }), + 'region=asia&quality=100', + ); + assertThrows( + () => optionsToQueryString({ region: Region.Asia, quality: 1001 }), + TypeError, + ); + assertThrows( + () => optionsToQueryString({ region: Region.Asia, quality: 1.5 }), + TypeError, + ); + assertThrows( + () => optionsToQueryString({ region: Region.Asia, quality: 0.4 }), + TypeError, + ); +}); +routes.set('/image-optimizer/options/saturation', () => { + assert( + optionsToQueryString({ region: Region.Asia, saturation: 1 }), + 'region=asia&saturation=1.000000', + ); + assert( + optionsToQueryString({ region: Region.Asia, saturation: 100 }), + 'region=asia&saturation=100.000000', + ); + assert( + optionsToQueryString({ region: Region.Asia, saturation: -100 }), + 'region=asia&saturation=-100.000000', + ); + assertThrows( + () => optionsToQueryString({ region: Region.Asia, saturation: 1001 }), + TypeError, + ); + assertThrows( + () => optionsToQueryString({ region: Region.Asia, saturation: -101 }), + TypeError, + ); +}); +routes.set('/image-optimizer/options/sharpen', () => { + assert( + optionsToQueryString({ + region: Region.Asia, + sharpen: { + amount: 10, + radius: 10, + threshold: 1, + }, + }), + 'region=asia&sharpen=a10.000000,r10.000000,t1', + ); + assert( + optionsToQueryString({ + region: Region.Asia, + sharpen: { + amount: 0.1, + radius: 0.5, + threshold: 255, + }, + }), + 'region=asia&sharpen=a0.100000,r0.500000,t255', + ); + assertThrows(() => { + optionsToQueryString({ + region: Region.Asia, + sharpen: { + amount: 0.1, + radius: 0.5, + threshold: 256, + }, + }); + }, TypeError); + assertThrows(() => { + optionsToQueryString({ + region: Region.Asia, + sharpen: { + amount: 0.1, + radius: 0.4, + threshold: 255, + }, + }); + }, TypeError); + assertThrows(() => { + optionsToQueryString({ + region: Region.Asia, + sharpen: { + amount: -1, + radius: 0.5, + threshold: 255, + }, + }); + }, TypeError); + assertThrows(() => { + optionsToQueryString({ + region: Region.Asia, + sharpen: { + amount: 1, + radius: 1, + }, + }); + }, TypeError); +}); +routes.set('/image-optimizer/options/trim', () => { + assert( + optionsToQueryString({ + region: Region.Asia, + trim: { + top: 10, + bottom: 20, + left: '10%', + right: '20%', + }, + }), + 'region=asia&trim=10,20.000000p,20,10.000000p', + ); + assertThrows( + () => + optionsToQueryString({ + region: Region.Asia, + trim: { + top: 10, + bottom: 20, + left: '10', + right: '20%', + }, + }), + TypeError, + ); + assertThrows( + () => + optionsToQueryString({ + region: Region.Asia, + trim: { + top: 10, + left: '10', + right: '20%', + }, + }), + TypeError, + ); +}); +routes.set('/image-optimizer/options/viewbox', () => { + assert( + optionsToQueryString({ region: Region.Asia, viewbox: 1 }), + 'region=asia&viewbox=1', + ); + assertThrows( + () => optionsToQueryString({ region: Region.Asia, viewbox: 2 }), + TypeError, + ); +}); +routes.set('/image-optimizer/options/width', () => { + assert( + optionsToQueryString({ region: Region.Asia, width: 1000 }), + 'region=asia&width=1000', + ); + assert( + optionsToQueryString({ region: Region.Asia, width: '10%' }), + 'region=asia&width=10.000000p', + ); + assertThrows( + () => optionsToQueryString({ region: Region.Asia, width: '1001' }), + TypeError, + ); + assertThrows( + () => optionsToQueryString({ region: Region.Asia, width: 100.5 }), + TypeError, + ); +}); diff --git a/integration-tests/js-compute/fixtures/app/src/index.js b/integration-tests/js-compute/fixtures/app/src/index.js index f17e3d8eaf..97525452e7 100644 --- a/integration-tests/js-compute/fixtures/app/src/index.js +++ b/integration-tests/js-compute/fixtures/app/src/index.js @@ -29,6 +29,7 @@ import './geoip.js'; import './headers.js'; import './html-rewriter.js'; import './include-bytes.js'; +import './image-optimizer.js'; import './logger.js'; import './manual-framing-headers.js'; import './missing-backend.js'; diff --git a/integration-tests/js-compute/fixtures/app/tests.json b/integration-tests/js-compute/fixtures/app/tests.json index 44bbb2a18d..6c4763e156 100644 --- a/integration-tests/js-compute/fixtures/app/tests.json +++ b/integration-tests/js-compute/fixtures/app/tests.json @@ -3092,6 +3092,34 @@ "GET /html-rewriter/invalid-html": {}, "GET /html-rewriter/insertion-order": {}, "GET /html-rewriter/escape-html": {}, + "GET /image-optimizer/options/region": {}, + "GET /image-optimizer/options/auto": {}, + "GET /image-optimizer/options/bw": {}, + "GET /image-optimizer/options/crop-mode": {}, + "GET /image-optimizer/options/disable": {}, + "GET /image-optimizer/options/enable": {}, + "GET /image-optimizer/options/fit": {}, + "GET /image-optimizer/options/metadata": {}, + "GET /image-optimizer/options/optimize": {}, + "GET /image-optimizer/options/orient": {}, + "GET /image-optimizer/options/profile": {}, + "GET /image-optimizer/options/resizeFilter": {}, + "GET /image-optimizer/options/bgColor": {}, + "GET /image-optimizer/options/blur": {}, + "GET /image-optimizer/options/brightness": {}, + "GET /image-optimizer/options/canvas": {}, + "GET /image-optimizer/options/crop": {}, + "GET /image-optimizer/options/precrop": {}, + "GET /image-optimizer/options/dpr": {}, + "GET /image-optimizer/options/frame": {}, + "GET /image-optimizer/options/height": {}, + "GET /image-optimizer/options/level": {}, + "GET /image-optimizer/options/pad": {}, + "GET /image-optimizer/options/quality": {}, + "GET /image-optimizer/options/saturation": {}, + "GET /image-optimizer/options/sharpen": {}, + "GET /image-optimizer/options/trim": {}, + "GET /image-optimizer/options/viewbox": {}, "GET /early-hints/manual-response": { "environments": ["compute"], "downstream_response": { diff --git a/integration-tests/js-compute/test.js b/integration-tests/js-compute/test.js index 5ad9ebee61..bf1aa7b4b5 100755 --- a/integration-tests/js-compute/test.js +++ b/integration-tests/js-compute/test.js @@ -136,7 +136,7 @@ if (!local) { core.startGroup('Delete service if already exists'); try { await zx`fastly service delete --quiet --service-name "${serviceName}" --force --token $FASTLY_API_TOKEN`; - } catch { } + } catch {} core.endGroup(); core.startGroup('Build and deploy service'); await zx`npm i`; @@ -170,13 +170,13 @@ await Promise.all([ 27, local ? [ - // we expect it to take ~10 seconds to deploy, so focus on that time - 6000, 3000, 1500, 500, 500, 500, 500, 500, 500, 500, 500, 500, 500, - 500, 500, 500, 500, 500, 500, 500, 500, 500, - // after more than 20 seconds, means we have an unusually slow build, start backoff before timeout - 1500, - 3000, 6000, 12000, 24000, - ].values() + // we expect it to take ~10 seconds to deploy, so focus on that time + 6000, 3000, 1500, 500, 500, 500, 500, 500, 500, 500, 500, 500, 500, + 500, 500, 500, 500, 500, 500, 500, 500, 500, + // after more than 20 seconds, means we have an unusually slow build, start backoff before timeout + 1500, + 3000, 6000, 12000, 24000, + ].values() : expBackoff('60s', '10s'), async () => { const response = await request(domain); @@ -275,12 +275,12 @@ for (const chunk of chunks(Object.entries(tests), 100)) { case 'first-chunk-only': for await (const chunk of response.body) { bodyChunks.push(chunk); - response.body.on('error', () => { }); + response.body.on('error', () => {}); break; } break; case 'none': - response.body.on('error', () => { }); + response.body.on('error', () => {}); break; case 'full': default: @@ -302,22 +302,22 @@ for (const chunk of chunks(Object.entries(tests), 100)) { } let onInfoHandler = test.downstream_info ? async (status, headers) => { - if ( - test.downstream_info.status !== undefined && - test.downstream_info.status != status - ) { - throw new Error( - `[DownstreamInfo: Status mismatch] Expected: ${configResponse.status} - Got: ${status}}`, - ); - } - if (headers) { - compareHeaders( - configResponse.headers, - headers, - configResponse.headersExhaustive, - ); + if ( + test.downstream_info.status !== undefined && + test.downstream_info.status != status + ) { + throw new Error( + `[DownstreamInfo: Status mismatch] Expected: ${configResponse.status} - Got: ${status}}`, + ); + } + if (headers) { + compareHeaders( + configResponse.headers, + headers, + configResponse.headersExhaustive, + ); + } } - } : undefined; if (local) { diff --git a/package.json b/package.json index f0c7582488..afe1016198 100644 --- a/package.json +++ b/package.json @@ -73,4 +73,4 @@ "optional": true } } -} +} \ No newline at end of file diff --git a/runtime/fastly/CMakeLists.txt b/runtime/fastly/CMakeLists.txt index 3bed3fc4c5..877085f8c2 100644 --- a/runtime/fastly/CMakeLists.txt +++ b/runtime/fastly/CMakeLists.txt @@ -28,6 +28,7 @@ add_builtin(fastly::dictionary SRC builtins/dictionary.cpp) add_builtin(fastly::edge_rate_limiter SRC builtins/edge-rate-limiter.cpp) add_builtin(fastly::config_store SRC builtins/config-store.cpp) add_builtin(fastly::secret_store SRC builtins/secret-store.cpp) +add_builtin(fastly::image_optimizer SRC builtins/image-optimizer.cpp) add_builtin(fastly::fetch SRC diff --git a/runtime/fastly/builtins/fastly.cpp b/runtime/fastly/builtins/fastly.cpp index fc6af0a903..207002d110 100644 --- a/runtime/fastly/builtins/fastly.cpp +++ b/runtime/fastly/builtins/fastly.cpp @@ -595,7 +595,8 @@ bool install(api::Engine *engine) { JS::SetOutOfMemoryCallback(engine->cx(), oom_callback, nullptr); - JS::RootedObject fastly(engine->cx(), JS_NewPlainObject(engine->cx())); + JS::RootedObject fastly(engine->cx()); + get_fastly_object(engine, &fastly); if (!fastly) { return false; } @@ -608,10 +609,6 @@ bool install(api::Engine *engine) { Fastly::baseURL.init(engine->cx()); Fastly::defaultBackend.init(engine->cx()); - if (!JS_DefineProperty(engine->cx(), engine->global(), "fastly", fastly, 0)) { - return false; - } - JSFunctionSpec nowfn = JS_FN("now", Fastly::now, 0, JSPROP_ENUMERATE); JSFunctionSpec end = JS_FS_END; diff --git a/runtime/fastly/builtins/fastly.h b/runtime/fastly/builtins/fastly.h index 93ece02108..e44c88c145 100644 --- a/runtime/fastly/builtins/fastly.h +++ b/runtime/fastly/builtins/fastly.h @@ -65,6 +65,26 @@ class Fastly : public builtins::BuiltinNoConstructor { JS::Result> convertBodyInit(JSContext *cx, JS::HandleValue bodyInit); +inline bool get_fastly_object(api::Engine *engine, JS::MutableHandleObject out) { + JS::RootedValue fastly_val(engine->cx()); + if (!JS_GetProperty(engine->cx(), engine->global(), "fastly", &fastly_val)) { + return false; + } + if (fastly_val.isObject()) { + out.set(&fastly_val.toObject()); + return true; + } + JS::RootedObject fastly_obj(engine->cx(), JS_NewPlainObject(engine->cx())); + if (!fastly_obj) { + return false; + } + if (!JS_DefineProperty(engine->cx(), engine->global(), "fastly", fastly_obj, 0)) { + return false; + } + out.set(fastly_obj); + return true; +} + /** * Debug only logging system, adding messages to `fastly.debugMessages` * diff --git a/runtime/fastly/builtins/fetch/fetch.cpp b/runtime/fastly/builtins/fetch/fetch.cpp index cb3d6588fe..f15468eaae 100644 --- a/runtime/fastly/builtins/fetch/fetch.cpp +++ b/runtime/fastly/builtins/fetch/fetch.cpp @@ -7,6 +7,7 @@ #include "../cache-override.h" #include "../fastly.h" #include "../fetch-event.h" +#include "../image-optimizer.h" #include "./request-response.h" #include "builtin.h" #include "encode.h" @@ -119,26 +120,9 @@ bool must_use_guest_caching(JSContext *cx, HandleObject request) { } bool http_caching_unsupported = false; -bool should_use_guest_caching(JSContext *cx, HandleObject request, bool *should_use_cache) { - *should_use_cache = true; - - // If we previously found guest caching unsupported then remember that - if (http_caching_unsupported || !fastly::fastly::ENABLE_EXPERIMENTAL_HTTP_CACHE) { - if (must_use_guest_caching(cx, request)) { - if (!fastly::fastly::ENABLE_EXPERIMENTAL_HTTP_CACHE) { - JS_ReportErrorASCII(cx, "HTTP caching API is not enabled for JavaScript; enable it with " - "the --enable-http-cache flag " - "to the js-compute build command, or contact support for help"); - } else { - JS_ReportErrorASCII( - cx, - "HTTP caching API is not enabled for this service; please contact support for help"); - } - return false; - } - *should_use_cache = false; - return true; - } +enum CachingMode { Guest, Host, ImageOptimizer }; +bool get_caching_mode(JSContext *cx, HandleObject request, CachingMode *caching_mode) { + *caching_mode = CachingMode::Guest; // Check for pass cache override MOZ_ASSERT(Request::is_instance(request)); @@ -148,7 +132,7 @@ bool should_use_guest_caching(JSContext *cx, HandleObject request, bool *should_ if (cache_override) { if (CacheOverride::mode(cache_override) == CacheOverride::CacheOverrideMode::Pass) { // Pass requests have to go through the host for now - *should_use_cache = false; + *caching_mode = CachingMode::Host; return true; } } @@ -161,7 +145,37 @@ bool should_use_guest_caching(JSContext *cx, HandleObject request, bool *should_ } if (is_purge) { // We don't yet implement guest-side URL purges - *should_use_cache = false; + *caching_mode = CachingMode::Host; + return true; + } + + // Requests meant for Image Optimizer should not be cached at this point, + // as the caching behavior is determined by the origin image, which is + // fetched after the request reaches the Image Optimizer WASM service. + // The WASM service uses cache_on_behalf to insert the result into + // the service's cache. + auto image_optimizer_opts = + JS::GetReservedSlot(request, static_cast(Request::Slots::ImageOptimizerOptions)); + if (!image_optimizer_opts.isNullOrUndefined()) { + *caching_mode = CachingMode::ImageOptimizer; + return true; + } + + // If we previously found guest caching unsupported then remember that + if (http_caching_unsupported || !fastly::fastly::ENABLE_EXPERIMENTAL_HTTP_CACHE) { + if (must_use_guest_caching(cx, request)) { + if (!fastly::fastly::ENABLE_EXPERIMENTAL_HTTP_CACHE) { + JS_ReportErrorASCII(cx, "HTTP caching API is not enabled for JavaScript; enable it with " + "the --enable-http-cache flag " + "to the js-compute build command, or contact support for help"); + } else { + JS_ReportErrorASCII( + cx, + "HTTP caching API is not enabled for this service; please contact support for help"); + } + return false; + } + *caching_mode = CachingMode::Host; return true; } @@ -177,7 +191,7 @@ bool should_use_guest_caching(JSContext *cx, HandleObject request, bool *should_ JS_ReportErrorASCII(cx, "HTTP caching API is not enabled; please contact support for help"); return false; } - *should_use_cache = false; + *caching_mode = CachingMode::Host; return true; } HANDLE_ERROR(cx, *err); @@ -188,8 +202,7 @@ bool should_use_guest_caching(JSContext *cx, HandleObject request, bool *should_ } // Sends the request body, resolving the response promise with the response -// The without_caching case is effectively pass semantics without cache hooks -template +template bool fetch_send_body(JSContext *cx, HandleObject request, JS::MutableHandleValue ret) { RootedString backend(cx, get_backend(cx, request)); if (!backend) { @@ -216,7 +229,7 @@ bool fetch_send_body(JSContext *cx, HandleObject request, JS::MutableHandleValue } // cache override only applies to requests with caching - if (!without_caching) { + if (caching_mode == CachingMode::Host) { if (!Request::apply_cache_override(cx, request)) { return false; } @@ -236,8 +249,10 @@ bool fetch_send_body(JSContext *cx, HandleObject request, JS::MutableHandleValue return false; } + // The image optimizer does not support streaming, so never stream in this case bool streaming = false; - if (!RequestOrResponse::maybe_stream_body(cx, request, &streaming)) { + if (caching_mode != CachingMode::ImageOptimizer && + !RequestOrResponse::maybe_stream_body(cx, request, &streaming)) { return false; } @@ -245,10 +260,40 @@ bool fetch_send_body(JSContext *cx, HandleObject request, JS::MutableHandleValue { auto request_handle = Request::request_handle(request); auto body = RequestOrResponse::body_handle(request); - auto res = !without_caching - ? streaming ? request_handle.send_async_streaming(body, backend_chars) - : request_handle.send_async(body, backend_chars) - : request_handle.send_async_without_caching(body, backend_chars, streaming); + host_api::Result res; + switch (caching_mode) { + case CachingMode::Host: { + if (streaming) { + res = request_handle.send_async_streaming(body, backend_chars); + } else { + res = request_handle.send_async(body, backend_chars); + } + break; + } + case CachingMode::Guest: + res = request_handle.send_async_without_caching(body, backend_chars, streaming); + break; + case CachingMode::ImageOptimizer: { + auto config = reinterpret_cast( + JS::GetReservedSlot(request, static_cast(Request::Slots::ImageOptimizerOptions)) + .toPrivate()); + auto config_str = config->to_string(); + auto res = request_handle.send_image_optimizer(body, backend_chars, config_str); + if (auto *err = res.to_err()) { + HANDLE_IMAGE_OPTIMIZER_ERROR(cx, *err); + ret.setObject(*PromiseRejectedWithPendingError(cx)); + return true; + } + + JS::RootedObject response(cx, Response::create(cx, request, res.unwrap())); + JS::RootedValue response_val(cx, JS::ObjectValue(*response)); + if (!JS::ResolvePromise(cx, response_promise, response_val)) { + return false; + } + ret.setObject(*response_promise); + return true; + } + } if (auto *err = res.to_err()) { if (host_api::error_is_generic(*err) || host_api::error_is_invalid_argument(*err)) { @@ -1075,13 +1120,15 @@ bool fetch(JSContext *cx, unsigned argc, Value *vp) { } // Determine if we should use guest-side caching - bool should_use_guest_caching_out; - if (!should_use_guest_caching(cx, request, &should_use_guest_caching_out)) { + CachingMode caching_mode; + if (!get_caching_mode(cx, request, &caching_mode)) { return false; } - if (!should_use_guest_caching_out) { + if (caching_mode == CachingMode::Host) { DEBUG_LOG("HTTP Cache: Using traditional fetch without cache API") - return fetch_send_body(cx, request, args.rval()); + return fetch_send_body(cx, request, args.rval()); + } else if (caching_mode == CachingMode::ImageOptimizer) { + return fetch_send_body(cx, request, args.rval()); } // Check if request is actually cacheable @@ -1104,7 +1151,7 @@ bool fetch(JSContext *cx, unsigned argc, Value *vp) { // If not cacheable, fallback to non-caching path if (!is_cacheable) { DEBUG_LOG("HTTP Cache: Request not cacheable, using non-caching fetch") - return fetch_send_body(cx, request, args.rval()); + return fetch_send_body(cx, request, args.rval()); } // Lookup in cache @@ -1224,7 +1271,7 @@ bool fetch(JSContext *cx, unsigned argc, Value *vp) { } // request collapsing has been disabled: pass the original request to the origin without // updating the cache and without caching - return fetch_send_body(cx, request, args.rval()); + return fetch_send_body(cx, request, args.rval()); } else { JS::RootedValue stream_back_promise(cx, JS::ObjectValue(*JS::NewPromiseObject(cx, nullptr))); if (!fetch_send_body_with_cache_hooks(cx, request, cache_entry, &stream_back_promise)) { diff --git a/runtime/fastly/builtins/fetch/request-response.cpp b/runtime/fastly/builtins/fetch/request-response.cpp index eeb8cf1e61..3ae1ebb545 100644 --- a/runtime/fastly/builtins/fetch/request-response.cpp +++ b/runtime/fastly/builtins/fetch/request-response.cpp @@ -21,6 +21,7 @@ #include "../cache-simple.h" #include "../fastly.h" #include "../fetch-event.h" +#include "../image-optimizer.h" #include "../kv-store.h" #include "extension-api.h" #include "fetch.h" @@ -1989,6 +1990,19 @@ bool Request::set_cache_key(JSContext *cx, JS::HandleObject self, JS::HandleValu return true; } +bool Request::set_image_optimizer_options(JSContext *cx, JS::HandleObject self, + JS::HandleValue opts_val) { + MOZ_ASSERT(is_instance(self)); + + auto opts = image_optimizer::ImageOptimizerOptions::create(cx, opts_val); + if (!opts) { + return false; + } + JS::SetReservedSlot(self, static_cast(Request::Slots::ImageOptimizerOptions), + JS::PrivateValue(opts.release())); + return true; +} + bool Request::set_cache_override(JSContext *cx, JS::HandleObject self, JS::HandleValue cache_override) { MOZ_ASSERT(is_instance(self)); @@ -2331,6 +2345,17 @@ bool Request::clone(JSContext *cx, unsigned argc, JS::Value *vp) { cache_override); } + JS::RootedValue image_optimizer_options( + cx, JS::GetReservedSlot(self, static_cast(Slots::ImageOptimizerOptions))); + if (!image_optimizer_options.isNullOrUndefined()) { + if (!set_image_optimizer_options(cx, requestInstance, image_optimizer_options)) { + return false; + } + } else { + JS::SetReservedSlot(requestInstance, static_cast(Slots::ImageOptimizerOptions), + image_optimizer_options); + } + args.rval().setObject(*requestInstance); return true; } @@ -2399,6 +2424,8 @@ JSObject *Request::create(JSContext *cx, JS::HandleObject requestInstance, JS::NullValue()); JS::SetReservedSlot(requestInstance, static_cast(Slots::CacheOverride), JS::NullValue()); + JS::SetReservedSlot(requestInstance, static_cast(Slots::ImageOptimizerOptions), + JS::NullValue()); JS::SetReservedSlot(requestInstance, static_cast(Slots::IsDownstream), JS::BooleanValue(is_downstream)); return requestInstance; @@ -2568,6 +2595,7 @@ JSObject *Request::create(JSContext *cx, JS::HandleObject requestInstance, JS::H JS::RootedValue cache_override(cx); JS::RootedValue cache_key(cx); JS::RootedValue fastly_val(cx); + JS::RootedValue image_optimizer_options(cx); bool hasManualFramingHeaders = false; bool setManualFramingHeaders = false; if (init_val.isObject()) { @@ -2581,7 +2609,8 @@ JSObject *Request::create(JSContext *cx, JS::HandleObject requestInstance, JS::H !JS_GetProperty(cx, init, "cacheKey", &cache_key) || !JS_GetProperty(cx, init, "fastly", &fastly_val) || !JS_HasOwnProperty(cx, init, "manualFramingHeaders", &hasManualFramingHeaders) || - !JS_GetProperty(cx, init, "manualFramingHeaders", &manualFramingHeaders)) { + !JS_GetProperty(cx, init, "manualFramingHeaders", &manualFramingHeaders) || + !JS_GetProperty(cx, init, "imageOptimizerOptions", &image_optimizer_options)) { return nullptr; } setManualFramingHeaders = manualFramingHeaders.isBoolean() && manualFramingHeaders.toBoolean(); @@ -2871,6 +2900,13 @@ JSObject *Request::create(JSContext *cx, JS::HandleObject requestInstance, JS::H } } + // Apply the Fastly Compute-proprietary `imageOptimizerOptions` property. + if (!image_optimizer_options.isUndefined()) { + if (!set_image_optimizer_options(cx, request, image_optimizer_options)) { + return nullptr; + } + } + if (fastly_val.isObject()) { JS::RootedValue decompress_response_val(cx); JS::RootedObject fastly(cx, fastly_val.toObjectOrNull()); diff --git a/runtime/fastly/builtins/fetch/request-response.h b/runtime/fastly/builtins/fetch/request-response.h index f4da11e657..9e30b2f9f4 100644 --- a/runtime/fastly/builtins/fetch/request-response.h +++ b/runtime/fastly/builtins/fetch/request-response.h @@ -179,6 +179,7 @@ class Request final : public builtins::BuiltinImpl { ResponsePromise, IsDownstream, AutoDecompressGzip, + ImageOptimizerOptions, Count, }; @@ -195,6 +196,8 @@ class Request final : public builtins::BuiltinImpl { JS::HandleValue cache_override_val); static bool apply_cache_override(JSContext *cx, JS::HandleObject self); static bool apply_auto_decompress_gzip(JSContext *cx, JS::HandleObject self); + static bool set_image_optimizer_options(JSContext *cx, JS::HandleObject self, + JS::HandleValue image_optimizer_options); static bool isCacheable_get(JSContext *cx, unsigned argc, JS::Value *vp); static host_api::HttpReq request_handle(JSObject *obj); diff --git a/runtime/fastly/builtins/image-optimizer-options.inc b/runtime/fastly/builtins/image-optimizer-options.inc new file mode 100644 index 0000000000..e685960370 --- /dev/null +++ b/runtime/fastly/builtins/image-optimizer-options.inc @@ -0,0 +1,120 @@ +// Defines X-Macros (https://en.wikipedia.org/wiki/X_macro) for enumerator image optimizer options + +#if !defined(FASTLY_DEFINE_IMAGE_OPTIMIZER_OPTION) && \ + !defined(FASTLY_BEGIN_IMAGE_OPTIMIZER_OPTION_TYPE) && \ + !defined(FASTLY_END_IMAGE_OPTIMIZER_OPTION_TYPE) +#error "Must define at least one of the FASTLY_DEFINE_IMAGE_OPTIMIZER_*** macros" +#endif + +#ifndef FASTLY_BEGIN_IMAGE_OPTIMIZER_OPTION_TYPE +#define FASTLY_BEGIN_IMAGE_OPTIMIZER_OPTION_TYPE(type, lowercase, str) +#endif +#ifndef FASTLY_DEFINE_IMAGE_OPTIMIZER_OPTION +#define FASTLY_DEFINE_IMAGE_OPTIMIZER_OPTION(name, str) +#endif +#ifndef FASTLY_END_IMAGE_OPTIMIZER_OPTION_TYPE +#define FASTLY_END_IMAGE_OPTIMIZER_OPTION_TYPE(type) +#endif + +FASTLY_BEGIN_IMAGE_OPTIMIZER_OPTION_TYPE(Region, region, "region") +FASTLY_DEFINE_IMAGE_OPTIMIZER_OPTION(UsEast, "us_east") +FASTLY_DEFINE_IMAGE_OPTIMIZER_OPTION(UsCentral, "us_central") +FASTLY_DEFINE_IMAGE_OPTIMIZER_OPTION(UsWest, "us_west") +FASTLY_DEFINE_IMAGE_OPTIMIZER_OPTION(EuCentral, "eu_central") +FASTLY_DEFINE_IMAGE_OPTIMIZER_OPTION(EuWest, "eu_west") +FASTLY_DEFINE_IMAGE_OPTIMIZER_OPTION(Asia, "asia") +FASTLY_DEFINE_IMAGE_OPTIMIZER_OPTION(Australia, "australia") +FASTLY_END_IMAGE_OPTIMIZER_OPTION_TYPE(Region) + +FASTLY_BEGIN_IMAGE_OPTIMIZER_OPTION_TYPE(Auto, auto, "auto") +FASTLY_DEFINE_IMAGE_OPTIMIZER_OPTION(AVIF, "avif") +FASTLY_DEFINE_IMAGE_OPTIMIZER_OPTION(WEBP, "webp") +FASTLY_END_IMAGE_OPTIMIZER_OPTION_TYPE(Auto) + +FASTLY_BEGIN_IMAGE_OPTIMIZER_OPTION_TYPE(Format, format, "format") +FASTLY_DEFINE_IMAGE_OPTIMIZER_OPTION(Auto, "auto") +FASTLY_DEFINE_IMAGE_OPTIMIZER_OPTION(AVIF, "avif") +FASTLY_DEFINE_IMAGE_OPTIMIZER_OPTION(BJPG, "bjpg") +FASTLY_DEFINE_IMAGE_OPTIMIZER_OPTION(GIF, "gif") +FASTLY_DEFINE_IMAGE_OPTIMIZER_OPTION(JPG, "jpg") +FASTLY_DEFINE_IMAGE_OPTIMIZER_OPTION(JXL, "jxl") +FASTLY_DEFINE_IMAGE_OPTIMIZER_OPTION(MP4, "mp4") +FASTLY_DEFINE_IMAGE_OPTIMIZER_OPTION(PJPG, "pjpg") +FASTLY_DEFINE_IMAGE_OPTIMIZER_OPTION(PJXL, "pjxl") +FASTLY_DEFINE_IMAGE_OPTIMIZER_OPTION(PNG, "png") +FASTLY_DEFINE_IMAGE_OPTIMIZER_OPTION(PNG8, "png8") +FASTLY_DEFINE_IMAGE_OPTIMIZER_OPTION(SVG, "svg") +FASTLY_DEFINE_IMAGE_OPTIMIZER_OPTION(WEBP, "webp") +FASTLY_DEFINE_IMAGE_OPTIMIZER_OPTION(WEBPLL, "webpll") +FASTLY_DEFINE_IMAGE_OPTIMIZER_OPTION(WEBPLY, "webply") +FASTLY_END_IMAGE_OPTIMIZER_OPTION_TYPE(Format) + +FASTLY_BEGIN_IMAGE_OPTIMIZER_OPTION_TYPE(BWAlgorithm, bw, "bw") +FASTLY_DEFINE_IMAGE_OPTIMIZER_OPTION(Threshold, "threshold") +FASTLY_DEFINE_IMAGE_OPTIMIZER_OPTION(Atkinson, "atkinson") +FASTLY_END_IMAGE_OPTIMIZER_OPTION_TYPE(BWAlgorithm) + +FASTLY_BEGIN_IMAGE_OPTIMIZER_OPTION_TYPE(Disable, disable, "disable") +FASTLY_DEFINE_IMAGE_OPTIMIZER_OPTION(Upscale, "upscale") +FASTLY_END_IMAGE_OPTIMIZER_OPTION_TYPE(Disable) + +FASTLY_BEGIN_IMAGE_OPTIMIZER_OPTION_TYPE(Enable, enable, "enable") +FASTLY_DEFINE_IMAGE_OPTIMIZER_OPTION(Upscale, "upscale") +FASTLY_END_IMAGE_OPTIMIZER_OPTION_TYPE(Enable) + +FASTLY_BEGIN_IMAGE_OPTIMIZER_OPTION_TYPE(Fit, fit, "fit") +FASTLY_DEFINE_IMAGE_OPTIMIZER_OPTION(Bounds, "bounds") +FASTLY_DEFINE_IMAGE_OPTIMIZER_OPTION(Cover, "cover") +FASTLY_DEFINE_IMAGE_OPTIMIZER_OPTION(Crop, "crop") +FASTLY_END_IMAGE_OPTIMIZER_OPTION_TYPE(Fit) + +FASTLY_BEGIN_IMAGE_OPTIMIZER_OPTION_TYPE(Metadata, metadata, "metadata") +FASTLY_DEFINE_IMAGE_OPTIMIZER_OPTION(Copyright, "copyright") +FASTLY_DEFINE_IMAGE_OPTIMIZER_OPTION(C2PA, "c2pa") +FASTLY_DEFINE_IMAGE_OPTIMIZER_OPTION(CopyrightAndC2PA, "copyright,c2pa") +FASTLY_END_IMAGE_OPTIMIZER_OPTION_TYPE(Metadata) + +FASTLY_BEGIN_IMAGE_OPTIMIZER_OPTION_TYPE(Optimize, optimize, "optimize") +FASTLY_DEFINE_IMAGE_OPTIMIZER_OPTION(Low, "low") +FASTLY_DEFINE_IMAGE_OPTIMIZER_OPTION(Medium, "medium") +FASTLY_DEFINE_IMAGE_OPTIMIZER_OPTION(High, "high") +FASTLY_END_IMAGE_OPTIMIZER_OPTION_TYPE(Optimize) + +FASTLY_BEGIN_IMAGE_OPTIMIZER_OPTION_TYPE(Orient, orient, "orient") +FASTLY_DEFINE_IMAGE_OPTIMIZER_OPTION(Default, "1") +FASTLY_DEFINE_IMAGE_OPTIMIZER_OPTION(FlipHorizontal, "2") +FASTLY_DEFINE_IMAGE_OPTIMIZER_OPTION(FlipHorizontalAndVertical, "3") +FASTLY_DEFINE_IMAGE_OPTIMIZER_OPTION(FlipVertical, "4") +FASTLY_DEFINE_IMAGE_OPTIMIZER_OPTION(FlipHorizontalOrientLeft, "5") +FASTLY_DEFINE_IMAGE_OPTIMIZER_OPTION(OrientRight, "6") +FASTLY_DEFINE_IMAGE_OPTIMIZER_OPTION(FlipHorizontalOrientRight, "7") +FASTLY_DEFINE_IMAGE_OPTIMIZER_OPTION(OrientLeft, "8") +FASTLY_END_IMAGE_OPTIMIZER_OPTION_TYPE(Orient) + +FASTLY_BEGIN_IMAGE_OPTIMIZER_OPTION_TYPE(Profile, profile, "profile") +FASTLY_DEFINE_IMAGE_OPTIMIZER_OPTION(Baseline, "baseline") +FASTLY_DEFINE_IMAGE_OPTIMIZER_OPTION(Main, "main") +FASTLY_DEFINE_IMAGE_OPTIMIZER_OPTION(High, "high") +FASTLY_END_IMAGE_OPTIMIZER_OPTION_TYPE(Profile) + +FASTLY_BEGIN_IMAGE_OPTIMIZER_OPTION_TYPE(ResizeFilter, resize_filter, "resize-filter") +FASTLY_DEFINE_IMAGE_OPTIMIZER_OPTION(Nearest, "nearest") +FASTLY_DEFINE_IMAGE_OPTIMIZER_OPTION(Bilinear, "bilinear") +FASTLY_DEFINE_IMAGE_OPTIMIZER_OPTION(Linear, "linear") +FASTLY_DEFINE_IMAGE_OPTIMIZER_OPTION(Bicubic, "bicubic") +FASTLY_DEFINE_IMAGE_OPTIMIZER_OPTION(Cubic, "cubic") +FASTLY_DEFINE_IMAGE_OPTIMIZER_OPTION(Lanczos2, "lanczos2") +FASTLY_DEFINE_IMAGE_OPTIMIZER_OPTION(Lanczos3, "lanczos3") +FASTLY_DEFINE_IMAGE_OPTIMIZER_OPTION(Lanczos, "lanczos") +FASTLY_END_IMAGE_OPTIMIZER_OPTION_TYPE(ResizeFilter) + +// CropMode is not a "true" option, but we reuse some of this machinery to easily define the +// constants +FASTLY_BEGIN_IMAGE_OPTIMIZER_OPTION_TYPE(CropMode, crop_mode, "N/A") +FASTLY_DEFINE_IMAGE_OPTIMIZER_OPTION(Smart, "smart") +FASTLY_DEFINE_IMAGE_OPTIMIZER_OPTION(Safe, "safe") +FASTLY_END_IMAGE_OPTIMIZER_OPTION_TYPE(Optimize) + +#undef FASTLY_DEFINE_IMAGE_OPTIMIZER_OPTION +#undef FASTLY_BEGIN_IMAGE_OPTIMIZER_OPTION_TYPE +#undef FASTLY_END_IMAGE_OPTIMIZER_OPTION_TYPE \ No newline at end of file diff --git a/runtime/fastly/builtins/image-optimizer.cpp b/runtime/fastly/builtins/image-optimizer.cpp new file mode 100644 index 0000000000..bd0a0b39c5 --- /dev/null +++ b/runtime/fastly/builtins/image-optimizer.cpp @@ -0,0 +1,763 @@ +#include "image-optimizer.h" +#include "fastly.h" + +namespace { +std::optional from_percentage(std::string_view sv) { + if (!sv.ends_with('%')) { + return std::nullopt; + } + char *end; + auto percentage = std::strtod(sv.data(), &end); + if (end != sv.data() + sv.size() - 1 || percentage == HUGE_VAL) { + return std::nullopt; + } + return percentage; +} + +std::optional to_number_between_inclusive(JS::HandleValue val, double from, double to) { + if (!val.isNumber()) { + return std::nullopt; + } + auto num = val.toNumber(); + if (num < from || num > to) { + return std::nullopt; + } + return num; +} + +bool exactly_one_defined(JS::HandleValue a, JS::HandleValue b) { + return (!a.isUndefined() || !b.isUndefined()) && (a.isUndefined() != b.isUndefined()); +} +} // namespace + +namespace fastly::image_optimizer { +const JSFunctionSpec EnumOption::static_methods[] = { + JS_FS_END, +}; +const JSFunctionSpec EnumOption::methods[] = { + JS_FS_END, +}; + +const JSPropertySpec EnumOption::properties[] = { + JS_PS_END, +}; + +#define FASTLY_BEGIN_IMAGE_OPTIMIZER_OPTION_TYPE(type, lowercase, str) \ + const JSPropertySpec type::static_properties[] = { +#define FASTLY_DEFINE_IMAGE_OPTIMIZER_OPTION(name, str) JS_STRING_PS(#name, str, JSPROP_READONLY), +#define FASTLY_END_IMAGE_OPTIMIZER_OPTION_TYPE(type) \ + JS_PS_END, \ + } \ + ; +#include "image-optimizer-options.inc" + +bool install(api::Engine *engine) { + RootedObject image_optimizer_ns(engine->cx(), JS_NewObject(engine->cx(), nullptr)); + +#define FASTLY_BEGIN_IMAGE_OPTIMIZER_OPTION_TYPE(type, lowercase, str) \ + if (!type::init_class_impl(engine->cx(), image_optimizer_ns)) { \ + return false; \ + } \ + RootedObject lowercase##_obj( \ + engine->cx(), \ + JS_GetConstructor( \ + engine->cx(), \ + builtins::BuiltinNoConstructor<::fastly::image_optimizer::type>::proto_obj)); \ + RootedValue lowercase##_val(engine->cx(), ObjectValue(*lowercase##_obj)); \ + if (!JS_SetProperty(engine->cx(), image_optimizer_ns, #type, lowercase##_val)) { \ + return false; \ + } +#define FASTLY_DEFINE_IMAGE_OPTIMIZER_OPTION(name, str) +#define FASTLY_END_IMAGE_OPTIMIZER_OPTION_TYPE(type) +#include "image-optimizer-options.inc" + + auto options_to_query_string_fn = JS_NewFunction( + engine->cx(), &ImageOptimizerOptions::optionsToQueryString, 1, 0, "optionsToQueryString"); + RootedObject options_to_query_string_obj(engine->cx(), + JS_GetFunctionObject(options_to_query_string_fn)); + RootedValue options_to_query_string_val(engine->cx(), + JS::ObjectValue(*options_to_query_string_obj)); + if (!JS_SetProperty(engine->cx(), image_optimizer_ns, "optionsToQueryString", + options_to_query_string_val)) { + return false; + } + + RootedValue image_optimizer_ns_val(engine->cx(), JS::ObjectValue(*image_optimizer_ns)); + if (!engine->define_builtin_module("fastly:image-optimizer", image_optimizer_ns_val)) { + return false; + } + + RootedObject fastly(engine->cx()); + if (!fastly::get_fastly_object(engine, &fastly)) { + return false; + } + if (!JS_SetProperty(engine->cx(), fastly, "imageOptimizer", image_optimizer_ns_val)) { + return false; + } + return true; +} // namespace fastly::image_optimizer + +std::unique_ptr ImageOptimizerOptions::create(JSContext *cx, + JS::HandleValue opts_val) { + // Extract properties. +#define FASTLY_BEGIN_IMAGE_OPTIMIZER_OPTION_TYPE(type, lowercase, str) \ + JS::RootedValue lowercase##_val(cx); +#include "image-optimizer-options.inc" + JS::RootedValue preserve_query_string_on_origin_request_val(cx); + JS::RootedValue bg_color_val(cx); + JS::RootedValue blur_val(cx); + JS::RootedValue brightness_val(cx); + JS::RootedValue canvas_val(cx); + JS::RootedValue contrast_val(cx); + JS::RootedValue crop_val(cx); + JS::RootedValue dpr_val(cx); + JS::RootedValue frame_val(cx); + JS::RootedValue height_val(cx); + JS::RootedValue level_val(cx); + JS::RootedValue pad_val(cx); + JS::RootedValue precrop_val(cx); + JS::RootedValue quality_val(cx); + JS::RootedValue saturation_val(cx); + JS::RootedValue sharpen_val(cx); + JS::RootedValue trim_val(cx); + JS::RootedValue trim_color_val(cx); + JS::RootedValue viewbox_val(cx); + JS::RootedValue width_val(cx); + JS::RootedObject opts(cx, opts_val.toObjectOrNull()); + if (!JS_GetProperty(cx, opts, "region", ®ion_val) || + !JS_GetProperty(cx, opts, "preserve_query_string_on_origin_request", + &preserve_query_string_on_origin_request_val) || + !JS_GetProperty(cx, opts, "auto", &auto_val) || + !JS_GetProperty(cx, opts, "bgColor", &bg_color_val) || + !JS_GetProperty(cx, opts, "blur", &blur_val) || + !JS_GetProperty(cx, opts, "brightness", &brightness_val) || + !JS_GetProperty(cx, opts, "bw", &bw_val) || + !JS_GetProperty(cx, opts, "canvas", &canvas_val) || + !JS_GetProperty(cx, opts, "contrast", &contrast_val) || + !JS_GetProperty(cx, opts, "crop", &crop_val) || + !JS_GetProperty(cx, opts, "disable", &disable_val) || + !JS_GetProperty(cx, opts, "dpr", &dpr_val) || + !JS_GetProperty(cx, opts, "enable", &enable_val) || + !JS_GetProperty(cx, opts, "fit", &fit_val) || + !JS_GetProperty(cx, opts, "format", &format_val) || + !JS_GetProperty(cx, opts, "frame", &frame_val) || + !JS_GetProperty(cx, opts, "height", &height_val) || + !JS_GetProperty(cx, opts, "level", &level_val) || + !JS_GetProperty(cx, opts, "metadata", &metadata_val) || + !JS_GetProperty(cx, opts, "optimize", &optimize_val) || + !JS_GetProperty(cx, opts, "orient", &orient_val) || + !JS_GetProperty(cx, opts, "pad", &pad_val) || + !JS_GetProperty(cx, opts, "precrop", &precrop_val) || + !JS_GetProperty(cx, opts, "profile", &profile_val) || + !JS_GetProperty(cx, opts, "quality", &quality_val) || + !JS_GetProperty(cx, opts, "resizeFilter", &resize_filter_val) || + !JS_GetProperty(cx, opts, "saturation", &saturation_val) || + !JS_GetProperty(cx, opts, "sharpen", &sharpen_val) || + !JS_GetProperty(cx, opts, "trim", &trim_val) || + !JS_GetProperty(cx, opts, "trimColor", &trim_color_val) || + !JS_GetProperty(cx, opts, "viewbox", &viewbox_val) || + !JS_GetProperty(cx, opts, "width", &width_val)) { + return nullptr; + } + + if (region_val.isUndefined()) { + api::throw_error(cx, api::Errors::TypeError, "Request", "imageOptimizerOptions", + "contain region"); + return nullptr; + } + +#define TRY_CONVERT(name) \ + decltype(to_##name(cx, name##_val)) name##_opt; \ + if (!name##_val.isUndefined()) { \ + name##_opt = to_##name(cx, name##_val); \ + if (!name##_opt) { \ + return nullptr; \ + } \ + } + +#define FASTLY_BEGIN_IMAGE_OPTIMIZER_OPTION_TYPE(type, lowercase, str) TRY_CONVERT(lowercase); +#include "image-optimizer-options.inc" + + TRY_CONVERT(bg_color); + TRY_CONVERT(blur); + TRY_CONVERT(brightness); + TRY_CONVERT(canvas); + TRY_CONVERT(contrast); + TRY_CONVERT(crop); + TRY_CONVERT(dpr); + TRY_CONVERT(frame); + TRY_CONVERT(height); + TRY_CONVERT(level); + TRY_CONVERT(pad); + TRY_CONVERT(precrop); + TRY_CONVERT(quality); + TRY_CONVERT(saturation); + TRY_CONVERT(sharpen); + TRY_CONVERT(trim); + TRY_CONVERT(trim_color); + TRY_CONVERT(viewbox); + TRY_CONVERT(width); + + return std::unique_ptr{new ImageOptimizerOptions( + *region_opt, auto_opt, std::move(bg_color_opt), blur_opt, brightness_opt, bw_opt, canvas_opt, + contrast_opt, crop_opt, disable_opt, dpr_opt, enable_opt, fit_opt, format_opt, frame_opt, + height_opt, std::move(level_opt), metadata_opt, optimize_opt, orient_opt, pad_opt, + precrop_opt, profile_opt, quality_opt, resize_filter_opt, saturation_opt, sharpen_opt, + trim_opt, std::move(trim_color_opt), viewbox_opt, width_opt)}; +} + +bool ImageOptimizerOptions::optionsToQueryString(JSContext *cx, unsigned argc, JS::Value *vp) { + JS::CallArgs args = CallArgsFromVp(argc, vp); + if (!args.requireAtLeast(cx, "optionsToQueryString", 1)) { + return false; + } + auto options = create(cx, args.get(0)); + if (!options) { + return false; + } + auto query = options->to_string(); + args.rval().setString(JS_NewStringCopyZ(cx, query.data())); + return true; +} + +std::string ImageOptimizerOptions::to_string() const { + using image_optimizer::to_string; + std::string ret = to_string(region_); + auto append = [&ret](auto &&v) { + if (v) + ret += "&" + to_string(*v); + }; + append(auto_); + append(bg_color_); + append(blur_); + append(brightness_); + append(bw_); + append(canvas_); + append(crop_); + append(contrast_); + append(disable_); + append(dpr_); + append(enable_); + append(fit_); + append(format_); + append(frame_); + append(height_); + append(level_); + append(metadata_); + append(optimize_); + append(orient_); + append(pad_); + append(precrop_); + append(profile_); + append(quality_); + append(resizeFilter_); + append(saturation_); + append(sharpen_); + append(trim_); + append(trim_color_); + append(viewbox_); + append(width_); + return ret; +} + +fastly_image_optimizer_transform_config ImageOptimizerOptions::to_config() { + fastly_image_optimizer_transform_config config; + auto str = this->to_string(); + host_api::HostString host_str(str); + config.sdk_claims_opts = host_str.ptr.release(); + config.sdk_claims_opts_len = host_str.len; + return config; +} + +std::optional ImageOptimizerOptions::to_size(JSContext *cx, + JS::HandleValue val) { + auto throw_error = [&](const char *object, const char *message) { + api::throw_error(cx, api::Errors::TypeError, "imageOptimizerOptions", object, message); + return std::nullopt; + }; + + if (val.isUndefined() || !val.isObject()) { + return throw_error("size", "be an object"); + } + + JS::RootedValue absolute_val(cx); + JS::RootedValue ratio_val(cx); + JS::RootedObject size_obj(cx, &val.toObject()); + if (!JS_GetProperty(cx, size_obj, "absolute", &absolute_val) || + !JS_GetProperty(cx, size_obj, "ratio", &ratio_val)) { + return std::nullopt; + } + if (!exactly_one_defined(absolute_val, ratio_val)) { + return throw_error("size", "have exactly one of the keys absolute or ratio"); + } + + JS::RootedValue width_val(cx); + JS::RootedValue height_val(cx); + if (!absolute_val.isUndefined()) { + if (!absolute_val.isObject()) { + return throw_error("size.absolute", "be an object"); + } + JS::RootedObject absolute_obj(cx, &absolute_val.toObject()); + if (!JS_GetProperty(cx, absolute_obj, "width", &width_val) || + !JS_GetProperty(cx, absolute_obj, "height", &height_val)) { + return std::nullopt; + } + auto width = to_pixels_or_percentage(cx, width_val); + auto height = to_pixels_or_percentage(cx, height_val); + if (!width || !height) { + return throw_error( + "size.absolute", + "have width and height values who are absolute pixel integers or percentage strings"); + } + return Size{Size::Absolute{*width, *height}}; + } else { + if (!ratio_val.isObject()) { + return throw_error("size.ratio", "be an object"); + } + JS::RootedObject ratio_obj(cx, &ratio_val.toObject()); + if (!JS_GetProperty(cx, ratio_obj, "width", &width_val) || + !JS_GetProperty(cx, ratio_obj, "height", &height_val)) { + return std::nullopt; + } + if (!width_val.isNumber() || !height_val.isNumber()) { + return throw_error("size.ratio", "have width and height number values"); + } + return Size{Size::Ratio{width_val.toNumber(), height_val.toNumber()}}; + } +} + +std::optional +ImageOptimizerOptions::to_position(JSContext *cx, JS::HandleValue val) { + auto throw_error = [&](const char *object, const char *message) { + api::throw_error(cx, api::Errors::TypeError, "imageOptimizerOptions", object, message); + return std::nullopt; + }; + JS::RootedValue x_val(cx); + JS::RootedValue y_val(cx); + JS::RootedValue offset_x_val(cx); + JS::RootedValue offset_y_val(cx); + + JS::RootedObject position_obj(cx, &val.toObject()); + if (!JS_GetProperty(cx, position_obj, "x", &x_val) || + !JS_GetProperty(cx, position_obj, "y", &y_val) || + !JS_GetProperty(cx, position_obj, "offsetX", &offset_x_val) || + !JS_GetProperty(cx, position_obj, "offsetY", &offset_y_val)) { + return std::nullopt; + } + if (!exactly_one_defined(x_val, offset_x_val) || !exactly_one_defined(y_val, offset_y_val)) { + return throw_error("position", "have exactly one of x/offsetX and exactly one of y/offsetY"); + } + + auto absolute_or_offset = + [&](JS::HandleValue absolute, + JS::HandleValue offset) -> std::optional> { + if (!absolute.isUndefined()) { + return to_pixels_or_percentage(cx, absolute); + } + if (offset.isNumber()) { + return offset.toNumber(); + } + return std::nullopt; + }; + auto x = absolute_or_offset(x_val, offset_x_val); + auto y = absolute_or_offset(y_val, offset_y_val); + if (!x || !y) { + return throw_error("position", "have valid x and y components"); + } + + return ImageOptimizerOptions::Position{*x, *y}; +} + +std::optional ImageOptimizerOptions::to_canvas(JSContext *cx, + JS::HandleValue val) { + auto throw_error = [&](const char *object, const char *message) { + api::throw_error(cx, api::Errors::TypeError, "imageOptimizerOptions", object, message); + return std::nullopt; + }; + if (!val.isObject()) { + return throw_error("canvas", "be an object"); + } + JS::RootedValue size_val(cx); + JS::RootedValue position_val(cx); + JS::RootedObject canvas_obj(cx, &val.toObject()); + if (!JS_GetProperty(cx, canvas_obj, "size", &size_val) || + !JS_GetProperty(cx, canvas_obj, "position", &position_val)) { + return std::nullopt; + } + auto size = to_size(cx, size_val); + if (!size) { + return std::nullopt; + } + + if (position_val.isUndefined()) { + return ImageOptimizerOptions::Canvas{*size, std::nullopt}; + } + auto position = to_position(cx, position_val); + if (!position) { + return std::nullopt; + } + return ImageOptimizerOptions::Canvas{*size, *position}; +} + +std::optional +ImageOptimizerOptions::to_crop_spec(JSContext *cx, JS::HandleValue val) { + auto throw_error = [&](const char *object, const char *message) { + api::throw_error(cx, api::Errors::TypeError, "imageOptimizerOptions", object, message); + return std::nullopt; + }; + if (!val.isObject()) { + return throw_error("crop", "be an object"); + } + JS::RootedValue size_val(cx); + JS::RootedValue position_val(cx); + JS::RootedValue mode_val(cx); + JS::RootedObject crop_obj(cx, &val.toObject()); + if (!JS_GetProperty(cx, crop_obj, "size", &size_val) || + !JS_GetProperty(cx, crop_obj, "position", &position_val) || + !JS_GetProperty(cx, crop_obj, "mode", &mode_val)) { + return std::nullopt; + } + auto size = to_size(cx, size_val); + if (!size) { + return std::nullopt; + } + + std::optional position = std::nullopt; + if (!position_val.isUndefined()) { + position = to_position(cx, position_val); + if (!position) { + return std::nullopt; + } + } + + std::optional mode = std::nullopt; + if (!mode_val.isUndefined()) { + mode = to_crop_mode(cx, mode_val); + if (!mode) { + return std::nullopt; + } + } + return ImageOptimizerOptions::CropSpec{*size, position, mode}; +} + +std::optional +ImageOptimizerOptions::to_brightness(JSContext *cx, JS::HandleValue val) { + auto num = to_number_between_inclusive(val, -100, 100); + if (!num) { + api::throw_error(cx, api::Errors::TypeError, "imageOptimizerOptions", "brightness", + "must be a number between -100 and 100"); + return std::nullopt; + } + return Brightness{*num}; +} +std::optional +ImageOptimizerOptions::to_contrast(JSContext *cx, JS::HandleValue val) { + auto num = to_number_between_inclusive(val, -100, 100); + if (!num) { + api::throw_error(cx, api::Errors::TypeError, "imageOptimizerOptions", "contrast", + "must be a number between -100 and 100"); + return std::nullopt; + } + return Contrast{*num}; +} +std::optional +ImageOptimizerOptions::to_quality(JSContext *cx, JS::HandleValue val) { + if (!val.isInt32() || val.toInt32() < 0 || val.toInt32() > 100) { + api::throw_error(cx, api::Errors::TypeError, "imageOptimizerOptions", "quality", + "must be n integer between 0 and 100"); + return std::nullopt; + } + return Quality(val.toInt32()); +} +std::optional +ImageOptimizerOptions::to_saturation(JSContext *cx, JS::HandleValue val) { + auto num = to_number_between_inclusive(val, -100, 100); + if (!num) { + api::throw_error(cx, api::Errors::TypeError, "imageOptimizerOptions", "saturation", + "must be a number between -100 and 100"); + return std::nullopt; + } + return Saturation{*num}; +} +std::optional ImageOptimizerOptions::to_dpr(JSContext *cx, + JS::HandleValue val) { + auto num = to_number_between_inclusive(val, 1, 10); + if (!num) { + api::throw_error(cx, api::Errors::TypeError, "imageOptimizerOptions", "dpr", + "must be a number between 1 and 10"); + return std::nullopt; + } + return Dpr{*num}; +} +std::optional ImageOptimizerOptions::to_level(JSContext *cx, + JS::HandleValue val) { + auto throw_error = [&]() { + api::throw_error( + cx, api::Errors::TypeError, "imageOptimizerOptions", "level", + "must be a string containing 1.0, 1.1, 1.2, 1.3, 2.0, 2.1, 2.2, 3.0, 3.1, 3.2, " + "4.0, 4.1, 4.2, 5.0, 5.1, 5.2, 6.0, 6.1, or 6.2"); + }; + if (!val.isString()) { + throw_error(); + return std::nullopt; + } + JS::RootedString js_str(cx, val.toString()); + auto str = core::encode(cx, js_str); + std::string_view sv = str; + if (sv != "1.0" && sv != "1.1" && sv != "1.2" && sv != "1.3" && sv != "2.0" && sv != "2.1" && + sv != "2.2" && sv != "3.0" && sv != "3.1" && sv != "3.2" && sv != "4.0" && sv != "4.1" && + sv != "4.2" && sv != "5.0" && sv != "5.1" && sv != "5.2" && sv != "6.0" && sv != "6.1" && + sv != "6.2") { + throw_error(); + return std::nullopt; + } + return Level{std::move(str)}; +} +std::optional ImageOptimizerOptions::to_frame(JSContext *cx, + JS::HandleValue val) { + if (!val.isInt32() || val.toInt32() != 1) { + api::throw_error(cx, api::Errors::TypeError, "imageOptimizerOptions", "frame", "must be 1"); + return std::nullopt; + } + return Frame{1}; +} +std::optional +ImageOptimizerOptions::to_viewbox(JSContext *cx, JS::HandleValue val) { + if (!val.isInt32() || val.toInt32() != 1) { + api::throw_error(cx, api::Errors::TypeError, "imageOptimizerOptions", "viewbox", "must be 1"); + return std::nullopt; + } + return Viewbox{1}; +} +std::optional ImageOptimizerOptions::to_blur(JSContext *cx, + JS::HandleValue val) { + auto throw_error = [&]() { + api::throw_error(cx, api::Errors::TypeError, "imageOptimizerOptions", "blur", + "must be a number between 0.5 and 1000 or a string percentage value"); + }; + if (val.isNumber()) { + auto num = to_number_between_inclusive(val, 0.5, 1000); + if (!num) { + throw_error(); + return std::nullopt; + } + return Blur{.value = *num, .is_percentage = false}; + } + if (!val.isString()) { + throw_error(); + return std::nullopt; + } + JS::RootedString js_str(cx, val.toString()); + auto str = core::encode(cx, js_str); + auto percentage = from_percentage(str); + if (!percentage) { + throw_error(); + return std::nullopt; + } + return Blur{.value = *percentage, .is_percentage = true}; +} +std::optional +ImageOptimizerOptions::to_sharpen(JSContext *cx, JS::HandleValue val) { + auto throw_error = [&]() { + api::throw_error(cx, api::Errors::TypeError, "imageOptimizerOptions", "sharpen", + "must be an object with keys `amount` (0.0-10.0), `radius` (0.5-1000.0), and " + "`threshold` (0-255)"); + }; + if (!val.isObject()) { + throw_error(); + return std::nullopt; + } + JS::RootedValue amount_val(cx); + JS::RootedValue radius_val(cx); + JS::RootedValue threshold_val(cx); + JS::RootedObject opts(cx, &val.toObject()); + if (!JS_GetProperty(cx, opts, "amount", &amount_val) || + !JS_GetProperty(cx, opts, "radius", &radius_val) || + !JS_GetProperty(cx, opts, "threshold", &threshold_val)) { + return std::nullopt; + } + auto amount = to_number_between_inclusive(amount_val, 0, 10); + auto radius = to_number_between_inclusive(radius_val, 0.5, 1000); + if (!amount || !radius || !threshold_val.isInt32() || threshold_val.toInt32() < 0 || + threshold_val.toInt32() > 255) { + throw_error(); + return std::nullopt; + } + return Sharpen{*amount, *radius, threshold_val.toInt32()}; +} +std::optional +ImageOptimizerOptions::to_pixels_or_percentage(JSContext *cx, JS::HandleValue val) { + if (val.isInt32()) { + return PixelsOrPercentage{.pixels = val.toInt32(), .is_percentage = false}; + } + if (!val.isString()) { + return std::nullopt; + } + JS::RootedString js_str(cx, val.toString()); + auto str = core::encode(cx, js_str); + auto percentage = from_percentage(str); + if (!percentage) { + return std::nullopt; + } + return PixelsOrPercentage{.percentage = *percentage, .is_percentage = true}; +} + +std::optional ImageOptimizerOptions::to_color(JSContext *cx, + JS::HandleValue val) { + // Hex strings of size 3 or 6 are allowed + if (val.isString()) { + JS::RootedString str_val(cx, val.toString()); + auto str = core::encode(cx, str_val); + if ((str.len == 3 || str.len == 6) && + (std::all_of(str.begin(), str.end(), [](char c) { return std::isxdigit(c); }))) { + return Color{std::move(str)}; + } + } + // Otherwise, it should be an rgb(a) object + if (!val.isObject()) { + return std::nullopt; + } + + JS::RootedValue r(cx); + JS::RootedValue g(cx); + JS::RootedValue b(cx); + JS::RootedValue a(cx); + JS::RootedObject obj(cx, &val.toObject()); + if (!JS_GetProperty(cx, obj, "r", &r) || !JS_GetProperty(cx, obj, "g", &g) || + !JS_GetProperty(cx, obj, "b", &b) || !JS_GetProperty(cx, obj, "a", &a)) { + return std::nullopt; + } + + auto is_valid_color_component = [](const auto &c) { + return c.isInt32() && c.toInt32() >= 0 && c.toInt32() < 256; + }; + auto alpha_valid = + a.isUndefined() || (a.isNumber() && a.toNumber() >= 0.0 && a.toNumber() <= 1.0); + if (!is_valid_color_component(r) || !is_valid_color_component(g) || + !is_valid_color_component(b) || !alpha_valid) { + return std::nullopt; + } + + std::string rep; + rep += std::to_string(r.toInt32()) + ',' + std::to_string(g.toInt32()) + ',' + + std::to_string(b.toInt32()); + if (!a.isUndefined()) { + rep += ',' + std::to_string(a.toNumber()); + } + return Color{host_api::HostString(rep)}; +} + +std::optional ImageOptimizerOptions::to_sides(JSContext *cx, + JS::HandleValue val) { + if (!val.isObject()) { + return std::nullopt; + } + + JS::RootedValue top_val(cx); + JS::RootedValue bottom_val(cx); + JS::RootedValue left_val(cx); + JS::RootedValue right_val(cx); + JS::RootedObject sides_obj(cx, &val.toObject()); + if (!JS_GetProperty(cx, sides_obj, "top", &top_val) || + !JS_GetProperty(cx, sides_obj, "bottom", &bottom_val) || + !JS_GetProperty(cx, sides_obj, "left", &left_val) || + !JS_GetProperty(cx, sides_obj, "right", &right_val)) { + return std::nullopt; + } + + if (top_val.isUndefined() || bottom_val.isUndefined() || left_val.isUndefined() || + right_val.isUndefined()) { + return std::nullopt; + } + + auto top = to_pixels_or_percentage(cx, top_val); + auto bottom = to_pixels_or_percentage(cx, bottom_val); + auto left = to_pixels_or_percentage(cx, left_val); + auto right = to_pixels_or_percentage(cx, right_val); + + if (!top || !bottom || !left || !right) { + return std::nullopt; + } + + return Sides{ + .top = *top, + .right = *right, + .bottom = *bottom, + .left = *left, + }; +} + +std::optional +ImageOptimizerOptions::to_trim_color(JSContext *cx, JS::HandleValue val) { + auto throw_error = [&]() { + api::throw_error(cx, api::Errors::TypeError, "imageOptimizerOptions", "trimColor", + "must be either a color, or an object with color and threshold elements, " + "where threshold is a number between 0 and 1"); + return std::nullopt; + }; + + if (val.isString()) { + auto color = to_color(cx, val); + if (!color) { + return throw_error(); + } + return TrimColor{*std::move(color), std::nullopt}; + } + + if (!val.isObject()) { + return throw_error(); + } + + JS::RootedValue color_val(cx); + JS::RootedValue threshold_val(cx); + JS::RootedObject trim_color_obj(cx, &val.toObject()); + if (!JS_GetProperty(cx, trim_color_obj, "color", &color_val) || + !JS_GetProperty(cx, trim_color_obj, "threshold", &threshold_val)) { + return std::nullopt; + } + + // Could be an rgb(a) object + if (color_val.isUndefined()) { + auto color = to_color(cx, val); + if (!color) { + return throw_error(); + } + return TrimColor{*std::move(color), std::nullopt}; + } + + auto color = to_color(cx, color_val); + if (!color) { + return throw_error(); + } + + std::optional threshold = std::nullopt; + if (threshold_val.isNumber()) { + threshold = to_number_between_inclusive(threshold_val, 0, 1); + if (!threshold) { + return throw_error(); + } + } + return TrimColor{*std::move(color), threshold}; +} + +#define FASTLY_BEGIN_IMAGE_OPTIMIZER_OPTION_TYPE(type, lowercase, str) \ + std::optional ImageOptimizerOptions::to_##lowercase( \ + JSContext *cx, JS::HandleValue val) { \ + if (!val.isString()) { \ + api::throw_error(cx, api::Errors::TypeError, "imageOptimizerOptions", #type, \ + "must be a string"); \ + return std::nullopt; \ + } \ + JS::RootedString str_val(cx, val.toString()); \ + using enum type; +#define FASTLY_DEFINE_IMAGE_OPTIMIZER_OPTION(name, str) \ + if (core::encode(cx, str_val) == std::string_view(str)) { \ + return name; \ + } +#define FASTLY_END_IMAGE_OPTIMIZER_OPTION_TYPE(type) \ + api::throw_error(cx, api::Errors::TypeError, "imageOptimizerOptions", #type, \ + "be one of the allowed string values"); \ + return std::nullopt; \ + } +#include "image-optimizer-options.inc" +} // namespace fastly::image_optimizer \ No newline at end of file diff --git a/runtime/fastly/builtins/image-optimizer.h b/runtime/fastly/builtins/image-optimizer.h new file mode 100644 index 0000000000..2cebb07763 --- /dev/null +++ b/runtime/fastly/builtins/image-optimizer.h @@ -0,0 +1,444 @@ +#ifndef FASTLY_IMAGE_OPTIMIZER_H +#define FASTLY_IMAGE_OPTIMIZER_H + +#include "../host-api/host_api_fastly.h" +#include "builtin.h" +#include "encode.h" +#include "extension-api.h" +#include +#include + +#define HANDLE_IMAGE_OPTIMIZER_ERROR(cx, err) \ + ::host_api::handle_image_optimizer_error(cx, err, __LINE__, __func__) + +namespace fastly::image_optimizer { +class EnumOption { +public: + enum Slots { Count }; + static const JSFunctionSpec static_methods[]; + static const JSFunctionSpec methods[]; + static const JSPropertySpec properties[]; +}; +#define FASTLY_BEGIN_IMAGE_OPTIMIZER_OPTION_TYPE(type, lowercase, str) \ + class type : public EnumOption, public builtins::BuiltinNoConstructor { \ + public: \ + static const JSPropertySpec static_properties[]; \ + static constexpr const char *class_name = #type; \ + }; +#include "image-optimizer-options.inc" + +class ImageOptimizerOptions { +public: + fastly_image_optimizer_transform_config to_config(); + static std::unique_ptr create(JSContext *cx, JS::HandleValue opts_val); + static bool optionsToQueryString(JSContext *cx, unsigned argc, JS::Value *vp); + std::string to_string() const; + +#define FASTLY_BEGIN_IMAGE_OPTIMIZER_OPTION_TYPE(type, lowercase, str) enum class type { +#define FASTLY_DEFINE_IMAGE_OPTIMIZER_OPTION(name, str) name, +#define FASTLY_END_IMAGE_OPTIMIZER_OPTION_TYPE(type) \ + } \ + ; +#include "image-optimizer-options.inc" + +#define FASTLY_BEGIN_IMAGE_OPTIMIZER_OPTION_TYPE(type, lowercase, str) \ + static std::optional to_##lowercase(JSContext *cx, JS::HandleValue val); +#include "image-optimizer-options.inc" + + struct Blur { + double value; + bool is_percentage; + }; + static std::optional to_blur(JSContext *cx, JS::HandleValue val); + + struct Brightness { + double value; + }; + static std::optional to_brightness(JSContext *cx, JS::HandleValue val); + + struct PixelsOrPercentage { + union { + int pixels; + double percentage; + }; + bool is_percentage; + }; + static std::optional to_pixels_or_percentage(JSContext *cx, + JS::HandleValue val); + + struct Size { + struct Absolute { + PixelsOrPercentage width; + PixelsOrPercentage height; + }; + struct Ratio { + double width_ratio; + double height_ratio; + }; + std::variant value; + }; + static std::optional to_size(JSContext *cx, JS::HandleValue val); + + struct Position { + // Absolute or offset + std::variant x; + std::variant y; + }; + static std::optional to_position(JSContext *cx, JS::HandleValue val); + struct Canvas { + Size size; + std::optional position; + }; + static std::optional to_canvas(JSContext *cx, JS::HandleValue val); + + struct Color { + host_api::HostString val; + }; + static std::optional to_color(JSContext *cx, JS::HandleValue val); + + struct Contrast { + double value; + }; + static std::optional to_contrast(JSContext *cx, JS::HandleValue val); + + // Deduplicates between Crop and Precrop + struct CropSpec { + Size size; + std::optional position; + std::optional mode; + }; + static std::optional to_crop_spec(JSContext *cx, JS::HandleValue val); + + struct Crop { + CropSpec value; + }; + static std::optional to_crop(JSContext *cx, JS::HandleValue val) { + auto value = to_crop_spec(cx, val); + if (!value) + return std::nullopt; + return Crop{*value}; + } + + struct Dpr { + double value; + }; + static std::optional to_dpr(JSContext *cx, JS::HandleValue val); + + struct Frame { + int32_t value; + }; + static std::optional to_frame(JSContext *cx, JS::HandleValue val); + + struct Height { + PixelsOrPercentage value; + }; + static std::optional to_height(JSContext *cx, JS::HandleValue val) { + auto ret = to_pixels_or_percentage(cx, val); + if (!ret) { + api::throw_error(cx, api::Errors::TypeError, "imageOptimizerOptions", "height", + "must be an integer pixel value or a string percentage value"); + return std::nullopt; + } + return Height{*ret}; + } + + struct Level { + host_api::HostString value; + }; + static std::optional to_level(JSContext *cx, JS::HandleValue val); + + struct Sides { + PixelsOrPercentage top, right, bottom, left; + }; + static std::optional to_sides(JSContext *cx, JS::HandleValue val); + + struct Pad { + Sides value; + }; + static std::optional to_pad(JSContext *cx, JS::HandleValue val) { + auto sides = to_sides(cx, val); + if (!sides) { + api::throw_error( + cx, api::Errors::TypeError, "imageOptimizerOptions", "trim", + "must be an object with top, right, bottom, and left elements, each being an " + "integer or a string percentage value"); + return std::nullopt; + } + return Pad{*sides}; + } + + struct Precrop { + CropSpec value; + }; + static std::optional to_precrop(JSContext *cx, JS::HandleValue val) { + auto value = to_crop_spec(cx, val); + if (!value) + return std::nullopt; + return Precrop{*value}; + } + + struct Quality { + uint32_t value; + }; + static std::optional to_quality(JSContext *cx, JS::HandleValue val); + + struct Saturation { + double value; + }; + static std::optional to_saturation(JSContext *cx, JS::HandleValue val); + + struct Sharpen { + double amount; + double radius; + int32_t threshold; + }; + static std::optional to_sharpen(JSContext *cx, JS::HandleValue val); + + struct Trim { + Sides value; + }; + static std::optional to_trim(JSContext *cx, JS::HandleValue val) { + auto sides = to_sides(cx, val); + if (!sides) { + api::throw_error( + cx, api::Errors::TypeError, "imageOptimizerOptions", "trim", + "must be an object with top, right, bottom, and left elements, each being an " + "integer or a string percentage value"); + return std::nullopt; + } + return Trim{*sides}; + } + + struct TrimColor { + Color color; + std::optional threshold; + }; + static std::optional to_trim_color(JSContext *cx, JS::HandleValue val); + + struct Viewbox { + int32_t value; + }; + static std::optional to_viewbox(JSContext *cx, JS::HandleValue val); + + struct Width { + PixelsOrPercentage value; + }; + static std::optional to_width(JSContext *cx, JS::HandleValue val) { + auto ret = to_pixels_or_percentage(cx, val); + if (!ret) { + api::throw_error(cx, api::Errors::TypeError, "imageOptimizerOptions", "width", + "must be an integer pixel value or a string percentage value"); + return std::nullopt; + } + return Width{*ret}; + } + + struct BGColor { + Color color; + }; + static std::optional to_bg_color(JSContext *cx, JS::HandleValue val) { + auto color = to_color(cx, val); + if (!color) { + api::throw_error(cx, api::Errors::TypeError, "imageOptimizerOptions", "bgColor", + "must be a 3/6 character hex string or RGB(A) object"); + return std::nullopt; + } + return BGColor{std::move(*color)}; + } + +private: + ImageOptimizerOptions( + Region region, std::optional auto_val, std::optional bg_color, + std::optional blur, std::optional brightness, std::optional bw, + std::optional canvas, std::optional contrast, std::optional crop, + std::optional disable, std::optional dpr, std::optional enable, + std::optional fit, std::optional format, std::optional frame, + std::optional height, std::optional level, std::optional metadata, + std::optional optimize, std::optional orient, std::optional pad, + std::optional precrop, std::optional profile, + std::optional quality, std::optional resize_filter, + std::optional saturation, std::optional sharpen, + std::optional trim, std::optional trim_color, std::optional viewbox, + std::optional width) + : region_(region), auto_(auto_val), bg_color_(std::move(bg_color)), blur_(blur), + brightness_(brightness), bw_(bw), canvas_(canvas), contrast_(contrast), crop_(crop), + disable_(disable), dpr_(dpr), enable_(enable), fit_(fit), format_(format), frame_(frame), + height_(height), level_(std::move(level)), metadata_(metadata), optimize_(optimize), + orient_(orient), pad_(pad), precrop_(precrop), profile_(profile), quality_(quality), + resizeFilter_(resize_filter), saturation_(saturation), sharpen_(sharpen), trim_(trim), + trim_color_(std::move(trim_color)), viewbox_(viewbox), width_(width) {} + Region region_; + std::optional auto_; + std::optional bg_color_; + std::optional blur_; + std::optional brightness_; + std::optional bw_; + std::optional canvas_; + std::optional contrast_; + std::optional crop_; + std::optional disable_; + std::optional dpr_; + std::optional enable_; + std::optional fit_; + std::optional format_; + std::optional frame_; + std::optional height_; + std::optional level_; + std::optional metadata_; + std::optional optimize_; + std::optional orient_; + std::optional pad_; + std::optional precrop_; + std::optional profile_; + std::optional quality_; + std::optional resizeFilter_; + std::optional saturation_; + std::optional sharpen_; + std::optional trim_; + std::optional trim_color_; + std::optional viewbox_; + std::optional width_; +}; + +inline std::string to_string(const ImageOptimizerOptions &opts) { return opts.to_string(); } + +#define FASTLY_BEGIN_IMAGE_OPTIMIZER_OPTION_TYPE(type, lowercase, str) \ + inline std::string to_string(ImageOptimizerOptions::type val) { \ + using enum ImageOptimizerOptions::type; \ + std::string prefix = str; \ + switch (val) { +#define FASTLY_DEFINE_IMAGE_OPTIMIZER_OPTION(name, str) \ + case name: \ + return prefix + '=' + str; +#define FASTLY_END_IMAGE_OPTIMIZER_OPTION_TYPE(type) \ + } \ + } +#include "image-optimizer-options.inc" + +inline std::string to_string(const ImageOptimizerOptions::Color &c) { + return std::string{std::string_view(c.val)}; +} +inline std::string to_string(const ImageOptimizerOptions::BGColor &bg) { + return "bg-color=" + to_string(bg.color); +} +inline std::string to_string(const ImageOptimizerOptions::Blur &blur) { + auto ret = "blur=" + std::to_string(blur.value); + if (blur.is_percentage) { + ret += 'p'; + } + return ret; +} +inline std::string to_string(const ImageOptimizerOptions::Brightness &brightness) { + return "brightness=" + std::to_string(brightness.value); +} +inline std::string to_string(const ImageOptimizerOptions::PixelsOrPercentage &value) { + if (value.is_percentage) { + return std::to_string(value.percentage) + 'p'; + } + return std::to_string(value.pixels); +} +inline std::string to_string(const ImageOptimizerOptions::Size &size) { + if (auto abs = std::get_if(&size.value)) { + return to_string(abs->width) + ',' + to_string(abs->height); + } + auto ratio = std::get(size.value); + return std::to_string(ratio.width_ratio) + ':' + std::to_string(ratio.height_ratio); +} +inline std::string to_string(const ImageOptimizerOptions::Position &position) { + std::string ret; + if (auto value = std::get_if(&position.x)) { + ret += 'x' + to_string(*value); + } else { + auto dbl = std::get(position.x); + ret += "offset-x" + std::to_string(dbl); + } + ret += ','; + if (auto value = std::get_if(&position.y)) { + ret += 'y' + to_string(*value); + } else { + auto dbl = std::get(position.y); + ret += "offset-y" + std::to_string(dbl); + } + return ret; +} +inline std::string to_string(const ImageOptimizerOptions::Canvas &canvas) { + std::string ret = "canvas=" + to_string(canvas.size); + if (canvas.position) { + ret += ',' + to_string(*canvas.position); + } + return ret; +} +inline std::string to_string(const ImageOptimizerOptions::Contrast &contrast) { + return "contrast=" + std::to_string(contrast.value); +} +inline std::string to_string(const ImageOptimizerOptions::CropSpec &crop) { + std::string ret = to_string(crop.size); + if (crop.position) { + ret += ',' + to_string(*crop.position); + } + if (crop.mode) { + ret += ','; + switch (*crop.mode) { + case ImageOptimizerOptions::CropMode::Safe: + ret += "safe"; + break; + case ImageOptimizerOptions::CropMode::Smart: + ret += "smart"; + break; + } + } + return ret; +} +inline std::string to_string(const ImageOptimizerOptions::Crop &crop) { + return "crop=" + to_string(crop.value); +} +inline std::string to_string(const ImageOptimizerOptions::Dpr &dpr) { + return "dpr=" + std::to_string(dpr.value); +} +inline std::string to_string(const ImageOptimizerOptions::Frame &frame) { + return "frame=" + std::to_string(frame.value); +} +inline std::string to_string(const ImageOptimizerOptions::Height &height) { + return "height=" + to_string(height.value); +} +inline std::string to_string(const ImageOptimizerOptions::Level &level) { + return "level=" + std::string(std::string_view(level.value)); +} +inline std::string to_string(const ImageOptimizerOptions::Sides &sides) { + return to_string(sides.top) + ',' + to_string(sides.right) + ',' + to_string(sides.bottom) + ',' + + to_string(sides.left); +} +inline std::string to_string(const ImageOptimizerOptions::Pad &pad) { + return "pad=" + to_string(pad.value); +} +inline std::string to_string(const ImageOptimizerOptions::Precrop &precrop) { + return "precrop=" + to_string(precrop.value); +} +inline std::string to_string(const ImageOptimizerOptions::Quality &quality) { + return "quality=" + std::to_string(quality.value); +} +inline std::string to_string(const ImageOptimizerOptions::Saturation &saturation) { + return "saturation=" + std::to_string(saturation.value); +} +inline std::string to_string(const ImageOptimizerOptions::Trim &trim) { + return "trim=" + to_string(trim.value); +} +inline std::string to_string(const ImageOptimizerOptions::TrimColor &trim_color) { + std::string ret = "trim-color=" + to_string(trim_color.color); + if (trim_color.threshold) { + ret += ",t" + std::to_string(*trim_color.threshold); + } + return ret; +} +inline std::string to_string(const ImageOptimizerOptions::Sharpen &sharpen) { + return "sharpen=a" + std::to_string(sharpen.amount) + ",r" + std::to_string(sharpen.radius) + + ",t" + std::to_string(sharpen.threshold); +} +inline std::string to_string(const ImageOptimizerOptions::Viewbox &viewbox) { + return "viewbox=" + std::to_string(viewbox.value); +} +inline std::string to_string(const ImageOptimizerOptions::Width &width) { + return "width=" + to_string(width.value); +} +} // namespace fastly::image_optimizer +#endif \ No newline at end of file diff --git a/runtime/fastly/host-api/fastly.h b/runtime/fastly/host-api/fastly.h index b0d0cb28c6..d1e2e86d8c 100644 --- a/runtime/fastly/host-api/fastly.h +++ b/runtime/fastly/host-api/fastly.h @@ -1103,6 +1103,32 @@ WASM_IMPORT("fastly_acl", "lookup") int acl_lookup(uint32_t acl_handle, const uint8_t *ip_octets, size_t ip_len, uint32_t *body_handle_out, fastly_acl_error *acl_error_out); +typedef struct __attribute__((aligned(8))) fastly_image_optimizer_transform_config { + const char *sdk_claims_opts; + size_t sdk_claims_opts_len; +} fastly_image_optimizer_transform_config; + +#define FASTLY_IMAGE_OPTIMIZER_RESERVED (1u << 0) +#define FASTLY_IMAGE_OPTIMIZER_SDK_CLAIMS_OPTS (1u << 1) + +#define FASTLY_IMAGE_OPTIMIZER_ERROR_TAG_UNINITIALIZED 0 +#define FASTLY_IMAGE_OPTIMIZER_ERROR_TAG_OK 1 +#define FASTLY_IMAGE_OPTIMIZER_ERROR_TAG_ERROR 2 +#define FASTLY_IMAGE_OPTIMIZER_ERROR_TAG_WARNING 3 + +typedef struct __attribute__((aligned(8))) fastly_image_optimizer_error_detail { + uint32_t tag; + const char *message; + size_t message_len; +} fastly_image_optimizer_error_detail; + +WASM_IMPORT("fastly_image_optimizer", "transform_image_optimizer_request") +int image_optimizer_transform_image_optimizer_request( + uint32_t req_handle, uint32_t body_handle, const char *backend, size_t backend_len, + int io_transform_config_mask, fastly_image_optimizer_transform_config *io_transform_config, + fastly_image_optimizer_error_detail *io_error_detail, uint32_t *resp_handle_out, + uint32_t *resp_body_handle_out); + #ifdef __cplusplus } // namespace fastly } // extern C diff --git a/runtime/fastly/host-api/host_api.cpp b/runtime/fastly/host-api/host_api.cpp index 6383b3bd61..e435174464 100644 --- a/runtime/fastly/host-api/host_api.cpp +++ b/runtime/fastly/host-api/host_api.cpp @@ -896,6 +896,31 @@ FastlyKVError make_fastly_kv_error(fastly::fastly_kv_error kv_error, return err; } +FastlyImageOptimizerError +make_fastly_image_optimizer_error(fastly::fastly_image_optimizer_error_detail im_err) { + FastlyImageOptimizerError::detail det; + switch (im_err.tag) { + case FASTLY_IMAGE_OPTIMIZER_ERROR_TAG_UNINITIALIZED: + det = FastlyImageOptimizerError::uninitialized; + break; + case FASTLY_IMAGE_OPTIMIZER_ERROR_TAG_OK: + det = FastlyImageOptimizerError::ok; + break; + case FASTLY_IMAGE_OPTIMIZER_ERROR_TAG_ERROR: + det = FastlyImageOptimizerError::error; + break; + case FASTLY_IMAGE_OPTIMIZER_ERROR_TAG_WARNING: + det = FastlyImageOptimizerError::warning; + break; + } + + return {det, std::string(im_err.message, im_err.message_len)}; +} + +FastlyImageOptimizerError make_fastly_image_optimizer_error(fastly::fastly_host_error err) { + return {err}; +} + } // namespace Result HttpBody::make() { @@ -1461,6 +1486,35 @@ Result HttpReq::send_async_without_caching(HttpBody body, std::s return res; } +FastlyResult +HttpReq::send_image_optimizer(HttpBody body, std::string_view backend, + std::string_view config_str) { + TRACE_CALL() + FastlyResult res; + + fastly::fastly_host_error err; + HttpReq::Handle orig_req_body_handle = INVALID_HANDLE; + fastly::fastly_world_string backend_str = string_view_to_world_string(backend); + auto opts = FASTLY_IMAGE_OPTIMIZER_SDK_CLAIMS_OPTS; + fastly::fastly_image_optimizer_transform_config config{config_str.data(), config_str.size()}; + fastly::fastly_image_optimizer_error_detail io_err_out{}; + uint32_t resp_handle_out = INVALID_HANDLE, body_handle_out = INVALID_HANDLE; + auto host_call_success = convert_result( + fastly::image_optimizer_transform_image_optimizer_request( + this->handle, orig_req_body_handle, reinterpret_cast(backend_str.ptr), + backend_str.len, opts, &config, &io_err_out, &resp_handle_out, &body_handle_out), + &err); + if (!host_call_success) { + res.emplace_err(make_fastly_image_optimizer_error(err)); + } else if (false && io_err_out.tag != FASTLY_IMAGE_OPTIMIZER_ERROR_TAG_OK) { + res.emplace_err(make_fastly_image_optimizer_error(io_err_out)); + } else { + res.emplace(HttpResp(resp_handle_out), HttpBody(body_handle_out)); + } + + return res; +} + Result HttpReq::set_method(std::string_view method) { TRACE_CALL() Result res; @@ -3612,6 +3666,22 @@ const std::optional FastlySendError::message() const { return "NetworkError when attempting to fetch resource."; } +const std::optional FastlyImageOptimizerError::message() const { + if (is_host_error) + return std::nullopt; + switch (err) { + case ok: + return std::nullopt; + case uninitialized: + return "Uninitialized: " + msg; + case warning: + return "Warning: " + msg; + case error: + return "Error: " + msg; + } + return std::nullopt; +} + bool BackendHealth::is_unknown() const { return this->state & FASTLY_HOST_BACKEND_BACKEND_HEALTH_UNKNOWN; } diff --git a/runtime/fastly/host-api/host_api_fastly.h b/runtime/fastly/host-api/host_api_fastly.h index ad444c67d0..74afd94ae4 100644 --- a/runtime/fastly/host-api/host_api_fastly.h +++ b/runtime/fastly/host-api/host_api_fastly.h @@ -515,6 +515,26 @@ enum class FramingHeadersMode : uint8_t { ManuallyFromHeaders, }; +class FastlyImageOptimizerError final { +public: + enum detail { uninitialized, ok, error, warning }; + + FastlyImageOptimizerError(detail err, std::string msg) + : err(err), is_host_error(false), msg(std::move(msg)) {} + FastlyImageOptimizerError(APIError host_err) : host_err(host_err), is_host_error(true) {} + + union { + APIError host_err; + detail err; + }; + bool is_host_error; + + const std::optional message() const; + +private: + std::string msg; +}; + class HttpReq final : public HttpBase { public: using Handle = uint32_t; @@ -568,6 +588,10 @@ class HttpReq final : public HttpBase { Result send_async_without_caching(HttpBody body, std::string_view backend, bool streaming = false); + /// Send this request synchronously to the Image Optimizer and wait for the response. + api::FastlyResult + send_image_optimizer(HttpBody body, std::string_view backend, std::string_view config_str); + /// Get the http version used for this request. /// Set the request method. @@ -1233,6 +1257,8 @@ class Compute final { void handle_api_error(JSContext *cx, uint8_t err, int line, const char *func); void handle_kv_error(JSContext *cx, host_api::FastlyKVError err, const unsigned int err_type, int line, const char *func); +void handle_image_optimizer_error(JSContext *cx, const host_api::FastlyImageOptimizerError &err, + int line, const char *func); bool error_is_generic(APIError e); bool error_is_invalid_argument(APIError e); diff --git a/runtime/fastly/host-api/host_call.cpp b/runtime/fastly/host-api/host_call.cpp index 4e9a7dbabe..22b1ebee1b 100644 --- a/runtime/fastly/host-api/host_call.cpp +++ b/runtime/fastly/host-api/host_call.cpp @@ -46,6 +46,16 @@ void handle_kv_error(JSContext *cx, FastlyKVError err, const unsigned int err_ty JS_ReportErrorNumberASCII(cx, fastly::FastlyGetErrorMessage, nullptr, err_type, message.c_str()); } +void handle_image_optimizer_error(JSContext *cx, const FastlyImageOptimizerError &err, int line, + const char *func) { + if (err.is_host_error) { + return handle_api_error(cx, err.host_err, line, func); + } + + std::string message = err.message().value(); + JS_ReportErrorUTF8(cx, "[Image Optimizer] %s", message.c_str()); +} + /* Returns false if an exception is set on `cx` and the caller should immediately return to propagate the exception. */ void handle_api_error(JSContext *cx, APIError err, int line, const char *func) { diff --git a/src/bundle.js b/src/bundle.js index 3050b17760..3b9ee4bd09 100644 --- a/src/bundle.js +++ b/src/bundle.js @@ -130,6 +130,14 @@ export const TransactionCacheEntry = globalThis.TransactionCacheEntry; contents: `export const HTMLRewritingStream = globalThis.HTMLRewritingStream;`, }; } + case 'image-optimizer': { + return { + contents: `export const { + Region, Auto, Format, BWAlgorithm, Disable, Enable, Fit, Metadata, + Optimize, Orient, Profile, ResizeFilter, CropMode, optionsToQueryString + } = globalThis.fastly.imageOptimizer;`, + }; + } } }); }, diff --git a/test-d/image-optimizer.test-d.ts b/test-d/image-optimizer.test-d.ts new file mode 100644 index 0000000000..5295d34b69 --- /dev/null +++ b/test-d/image-optimizer.test-d.ts @@ -0,0 +1,8 @@ +/// +import { + ImageOptimizerOptions, + optionsToQueryString, +} from 'fastly:image-optimizer'; +import { expectType } from 'tsd'; + +expectType<(options: ImageOptimizerOptions) => string>(optionsToQueryString); diff --git a/types/globals.d.ts b/types/globals.d.ts index 7f2462da69..84b0b52aff 100644 --- a/types/globals.d.ts +++ b/types/globals.d.ts @@ -1337,6 +1337,7 @@ declare interface RequestInit { decompressGzip?: boolean; }; manualFramingHeaders?: boolean; + imageOptimizerOptions?: import('fastly:image-optimizer').ImageOptimizerOptions; } /** diff --git a/types/image-optimizer.d.ts b/types/image-optimizer.d.ts new file mode 100644 index 0000000000..9090d4d776 --- /dev/null +++ b/types/image-optimizer.d.ts @@ -0,0 +1,284 @@ +declare module 'fastly:image-optimizer' { + /** + * A color, either a 3/6 character hex string or an rgb(a) object. + */ + type Color = + | string + | { + r: number; + g: number; + b: number; + a?: number; + }; + /** + * A percentage, expressed as a string such as '100%' + */ + type Percentage = string; + /** + * The size of a region, either expressed as absolute values (either integer pixel values or percentage strings), or as a ratio of integers. + */ + interface Size { + absolute?: { + width: number | Percentage; + height: number | Percentage; + }; + ratio?: { + width: number; + height: number; + }; + } + /** + * The position of a region, with x and y components expressed as either integer pixel values/percentage strings, or offset percentages. + */ + interface Position { + x?: number | Percentage; + offsetX?: number; + y?: number | Percentage; + offsetY?: number; + } + + interface Sides { + top: number | Percentage; + bottom: number | Percentage; + left: number | Percentage; + right: number | Percentage; + } + + var Region: { + UsEast: 'us_east'; + UsCentral: 'us_central'; + UsWest: 'us_west'; + EuCentral: 'eu_central'; + Asia: 'asia'; + Australia: 'australia'; + }; + var Auto: { + AVIF: 'avif'; + WEBP: 'webp'; + }; + var BWAlgorithm: { + Threshold: 'threshold'; + Atkinson: 'atkinson'; + }; + var CropMode: { + Smart: 'smart'; + Safe: 'safe'; + }; + var Disable: { + Upscale: 'upscale'; + }; + var Enable: { + Upscale: 'upscale'; + }; + var Fit: { + Bounds: 'bounds'; + Cover: 'cover'; + Crop: 'crop'; + }; + var Metadata: { + Copyright: 'copyright'; + C2PA: 'c2pa'; + CopyrightAndC2PA: 'copyright,c2pa'; + }; + var Optimize: { + Low: 'low'; + Medium: 'medium'; + High: 'high'; + }; + var Orient: { + Default: '1'; + FlipHorizontal: '2'; + FlipHorizontalAndVertical: '3'; + FlipVertical: '4'; + FlipHorizontalOrientLeft: '5'; + OrientRight: '6'; + FlipHorizontalOrientRight: '7'; + OrientLeft: '8'; + }; + var Profile: { + Baseline: 'baseline'; + Main: 'main'; + High: 'high'; + }; + var ResizeFilter: { + Nearest: 'nearest'; + Bilinear: 'bilinear'; + Linear: 'linear'; + Bicubic: 'bicubic'; + Cubic: 'cubic'; + Lanczos2: 'lanczos2'; + Lanczos3: 'lanczos3'; + Lanczos: 'lanczos'; + }; + + interface ImageOptimizerOptions { + /** + * + */ + region: + | 'us_east' + | 'us_central' + | 'us_west' + | 'eu_central' + | 'asia' + | 'australia'; + /** + * Enable optimization features automatically. + */ + auto?: 'avif' | 'webp'; + /** + * Set the background color of an image. + */ + bgColor?: Color; + /** + * Set the blurriness of the output image (0.5-1000). + */ + blur?: number | Percentage; + /** + * Set the brightness of the output image (-100,100). + */ + brightness?: number; + /** + * Convert an image to black and white using a given algorithm. + */ + bw?: 'threshold' | 'atkinson'; + /** + * Increase the size of the canvas around an image. + */ + canvas?: { + size: Size; + position?: Position; + }; + /** + * Set the contrast of the output image (-100-100). + */ + contrast?: number; + /** + * Remove pixels from an image. + */ + crop?: { + size: Size; + position?: Position; + mode?: 'smart' | 'safe'; + }; + /** + * Disable functionality that is enabled by default. + */ + disable?: 'upscale'; + /** + * Ratio between physical pixels and logical pixels (1-10). + */ + dpr?: number; + /** + * Enable functionality that is disabled by default. + */ + enable?: 'upscale'; + /** + * Set how the image will fit within the size bounds provided. + */ + fit?: 'bounds' | 'cover' | 'crop'; + /** + * Specify the output format to convert the image to. + */ + format?: + | 'auto' + | 'avif' + | 'bjpg' + | 'gif' + | 'jpg' + | 'jxl' + | 'mp4' + | 'pjpg' + | 'pjxl' + | 'png' + | 'png8' + | 'svg' + | 'webp' + | 'webpll' + | 'webply'; + /** + * Extract the first frame from an animated image. + */ + frame?: 1; + /** + * Resize the height of the image. + */ + height?: number | Percentage; + /** + * Specify the level constraints when converting to video. + */ + level?: string; + /** + * Control which metadata fields are preserved during transformation. + */ + metadata?: 'copyright' | 'c2pa' | 'copyright,c2pa'; + /** + * Automatically apply optimal quality compression. + */ + optimize?: 'low' | 'medium' | 'high'; + /** + * Change the cardinal orientation of the image. + */ + orient?: '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8'; + /** + * Add pixels to the edge of an image + */ + pad?: Sides; + /** + * Remove pixels from an image before any other transformations occur. + */ + precrop?: { + size: Size; + position?: Position; + mode?: 'smart' | 'safe'; + }; + /** + * Specify the profile class of application when converting to video. + */ + profile?: 'baseline' | 'main' | 'high'; + /** + * Optimize the image to the given compresion level for lossy file formatted images (1-100). + */ + quality?: number; + /** + * Specify the resize filter used when resizing images. + */ + resizeFilter?: + | 'nearest' + | 'bilinear' + | 'linear' + | 'bicubic' + | 'cubic' + | 'lanczos2' + | 'lanczos3' + | 'lanczos'; + /** + * Set the saturation of the output image (-100-100). + */ + saturation?: number; + /** + * Set the sharpness of the output image. + */ + sharpen?: { + amount: number; + radius: number; + threshold: number; + }; + /** + * Remove pixels from the edge of an image. + */ + trim?: Sides; + /** + * Remove explicit width and height properties in SVG output. + */ + viewbox?: 1; + /** + * Resize the width of the image. + */ + width?: number | Percentage; + } + /** + * Convert image optimizer options into the query string that is sent to the image optimizer, for logging and debugging purposes. + */ + function optionsToQueryString(options: ImageOptimizerOptions): string; +} diff --git a/types/index.d.ts b/types/index.d.ts index 4bdd2bc169..39b052dfca 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -18,3 +18,4 @@ /// /// /// +///