The HTTP service: assets, probe, and the full sharp surface
Exposes sharp and ffprobe with their own vocabulary — operations are named what sharp names them, ffprobe fields come back spelled the way ffprobe spells them — so the tools' own documentation applies and there is no house dialect to learn. Safety comes from one architectural invariant rather than from filtering: the caller never supplies a path, a URL or a protocol. Input is an id we issued, output is a name we chose. That removes the entire class of attack that makes "just pass the flags through" a remote shell, which is precisely what makes it safe to open up everything else. docs/SECURITY.md has the reasoning and the specific attacks. The vocabulary is a data table, so the validator and docs/SHARP.md are generated from one source and cannot drift. Two things are deliberately absent from it: withMetadata, because that is how a client's GPS coordinates get published, and limitInputPixels, because a caller who could raise it could turn a 1 KB PNG into a 40 GB allocation. Verified against a running server, not asserted: traversal ids rejected, unknown operations and typo'd parameters rejected by name, out-of-range values rejected with the bound, a GPS-tagged photo coming out clean end to end, video probe, Range requests, and a rejected upload leaving nothing on disk. Found while building: config threw at import if AV_CLUB_TOKEN was unset, so generating documentation required a production secret. The token is read at boot in server.ts now — still a refusal to start, no longer a module-scope landmine. ffmpeg is designed in docs/API.md and not built yet. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
81c8e17a8e
commit
1597fccd75
+208
@@ -0,0 +1,208 @@
|
||||
# The HTTP API
|
||||
|
||||
av-club is a Bun HTTP service. It exposes as much of sharp, ffmpeg and ffprobe as can be exposed
|
||||
safely, and it keeps each tool's own vocabulary: sharp operations are named what sharp names them,
|
||||
ffmpeg options are spelled the way ffmpeg spells them. If you know the tool, you know this API.
|
||||
|
||||
What it does **not** let you do is name a file. See `docs/SECURITY.md` — that single restriction is
|
||||
what makes the rest of the surface safe to open up.
|
||||
|
||||
## Status
|
||||
|
||||
| Endpoint | |
|
||||
|---|---|
|
||||
| `POST /v1/assets`, `GET /v1/assets/:id`, `GET /v1/assets/:id/content`, `DELETE /v1/assets/:id` | implemented |
|
||||
| `GET /v1/assets/:id/probe` | implemented |
|
||||
| `POST /v1/sharp` | implemented |
|
||||
| `POST /v1/ffmpeg` | **not yet** — designed at the end of this document |
|
||||
| `GET /health` | implemented |
|
||||
|
||||
## The shape of everything
|
||||
|
||||
**Nothing takes a path or a URL.** You upload bytes, you get an **asset id**, and every subsequent
|
||||
operation refers to that id. Operations produce new assets and return new ids; inputs are never
|
||||
modified.
|
||||
|
||||
```
|
||||
upload ──> as_a1b2… ──sharp──> as_c3d4… ──download
|
||||
```
|
||||
|
||||
Ids look like `as_` followed by 32 hex characters.
|
||||
|
||||
## Authentication
|
||||
|
||||
Every endpoint except `/health` requires:
|
||||
|
||||
```
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
The token is `AV_CLUB_TOKEN` in the service's environment. There are no user accounts — this is an
|
||||
internal service between things we run.
|
||||
|
||||
## Errors
|
||||
|
||||
One envelope, always, so a client can parse a failure the same way it parses a success:
|
||||
|
||||
```json
|
||||
{ "error": { "message": "resize.width must be an integer between 1 and 16383", "type": "invalid_request", "param": "pipeline[1].resize.width" } }
|
||||
```
|
||||
|
||||
| `type` | Status | Meaning |
|
||||
|---|---|---|
|
||||
| `invalid_request` | 400 | The request is malformed or an argument is out of range. `param` says where. |
|
||||
| `unauthorized` | 401 | Missing or wrong bearer token. |
|
||||
| `not_found` | 404 | No such asset. |
|
||||
| `payload_too_large` | 413 | Upload exceeds the size cap. |
|
||||
| `unprocessable` | 422 | Valid request, but the file could not be processed — corrupt, or not the format it claimed. |
|
||||
| `timeout` | 504 | The job exceeded its wall clock limit and was killed. |
|
||||
| `server_error` | 500 | Our fault. |
|
||||
|
||||
Messages are written to be readable by a person, because the thing on the other end is often
|
||||
relaying them to one.
|
||||
|
||||
---
|
||||
|
||||
## Assets
|
||||
|
||||
### `POST /v1/assets`
|
||||
|
||||
`multipart/form-data` with a `file` field.
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:3020/v1/assets \
|
||||
-H "Authorization: Bearer $AV_CLUB_TOKEN" \
|
||||
-F file=@photo.heic
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "as_9f86d081884c7d659a2feaa0c55ad015",
|
||||
"bytes": 2847293,
|
||||
"sha256": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
|
||||
"kind": "image",
|
||||
"format": "heif",
|
||||
"width": 4032,
|
||||
"height": 3024,
|
||||
"createdAt": "2026-08-20T01:40:00.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
`kind` is one of `image`, `video`, `audio`. It is determined by **inspecting the bytes**, never from
|
||||
the filename or the browser's `Content-Type` — see `docs/SECURITY.md`.
|
||||
|
||||
The upload is rejected with `422` if it is not media we can decode, and with `413` if it exceeds
|
||||
`AV_CLUB_MAX_UPLOAD_BYTES`.
|
||||
|
||||
### `GET /v1/assets/:id`
|
||||
|
||||
The same JSON as above.
|
||||
|
||||
### `GET /v1/assets/:id/content`
|
||||
|
||||
The bytes, with the right `Content-Type`. Supports `Range` requests, which matters because a browser
|
||||
will not scrub — often will not even play — a `<video>` served without it.
|
||||
|
||||
### `DELETE /v1/assets/:id`
|
||||
|
||||
`204`. Deleting an input does not affect outputs already derived from it.
|
||||
|
||||
### `GET /v1/assets/:id/probe`
|
||||
|
||||
Everything the tools can tell you about the file.
|
||||
|
||||
For images this is sharp's `metadata()` plus `stats()`. For video and audio it is `ffprobe` with
|
||||
`-show_format -show_streams`, passed through with **ffprobe's own field names** — `codec_name`,
|
||||
`bit_rate`, `r_frame_rate`, `nb_frames` and the rest, exactly as ffprobe emits them.
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": "video",
|
||||
"format": { "format_name": "mov,mp4,m4a,3gp,3g2,mj2", "duration": "12.480000", "bit_rate": "2489331" },
|
||||
"streams": [
|
||||
{ "codec_type": "video", "codec_name": "h264", "width": 1920, "height": 1080, "r_frame_rate": "30/1", "pix_fmt": "yuv420p" },
|
||||
{ "codec_type": "audio", "codec_name": "aac", "sample_rate": "48000", "channels": 2 }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## sharp
|
||||
|
||||
### `POST /v1/sharp`
|
||||
|
||||
A sharp pipeline, expressed as an ordered list of operations. **Every operation is named exactly what
|
||||
sharp names it, and its arguments are sharp's arguments**, so sharp's own documentation is the
|
||||
reference for what each one does.
|
||||
|
||||
```json
|
||||
{
|
||||
"input": "as_9f86d081884c7d659a2feaa0c55ad015",
|
||||
"options": { "failOn": "warning", "animated": false },
|
||||
"pipeline": [
|
||||
{ "rotate": {} },
|
||||
{ "resize": { "width": 1200, "fit": "inside", "withoutEnlargement": true } },
|
||||
{ "modulate": { "saturation": 1.05 } }
|
||||
],
|
||||
"output": { "format": "avif", "options": { "quality": 55, "effort": 4 } }
|
||||
}
|
||||
```
|
||||
|
||||
`options` are sharp's **constructor** options. `pipeline` runs in order. `output` is the format and
|
||||
its encoder options.
|
||||
|
||||
Returns a new asset:
|
||||
|
||||
```json
|
||||
{ "id": "as_c3d4…", "bytes": 84213, "kind": "image", "format": "avif", "width": 1200, "height": 900 }
|
||||
```
|
||||
|
||||
**The full operation vocabulary, the arguments each takes, and the bounds enforced on them are in
|
||||
[`docs/SHARP.md`](SHARP.md).** It is generated from the same table the validator uses, so it cannot
|
||||
drift from what the server actually accepts.
|
||||
|
||||
### Things worth knowing
|
||||
|
||||
**`rotate` with no angle** reads the EXIF orientation and applies it. Since output is re-encoded and
|
||||
metadata is dropped, an image that was upright in a phone's gallery arrives sideways on the web
|
||||
unless you do this. It is the single most commonly forgotten operation.
|
||||
|
||||
**Metadata is stripped.** Sharp does this by default and av-club does not offer `withMetadata()`.
|
||||
That is a deliberate refusal, not an omission: it is how a client's GPS coordinates get published.
|
||||
If you need dimensions, they are in the response.
|
||||
|
||||
**`resize` does not enlarge unless you let it.** Pass `withoutEnlargement: true` or a 300px source
|
||||
asked for 800px comes back 800px and soft.
|
||||
|
||||
---
|
||||
|
||||
## ffmpeg — designed, not yet built
|
||||
|
||||
`POST /v1/ffmpeg` will take ffmpeg's own option names:
|
||||
|
||||
```json
|
||||
{
|
||||
"input": "as_9f86…",
|
||||
"inputOptions": { "ss": 5, "t": 10 },
|
||||
"filters": { "v": [ { "scale": { "w": 1280, "h": -2 } }, { "fps": { "fps": 30 } } ] },
|
||||
"outputOptions": {
|
||||
"c:v": "libx264", "crf": 23, "preset": "medium", "pix_fmt": "yuv420p",
|
||||
"c:a": "aac", "b:a": "128k", "movflags": "+faststart"
|
||||
},
|
||||
"format": "mp4"
|
||||
}
|
||||
```
|
||||
|
||||
Two deliberate differences from the command line, both from `docs/SECURITY.md`:
|
||||
|
||||
**Filters are structured, not a `-vf` string.** ffmpeg's filter syntax is a small language with its
|
||||
own escaping rules, and parsing it correctly enough to validate it is harder than accepting a list of
|
||||
`{ name: args }` objects. The names and parameters are ffmpeg's; only the punctuation is ours.
|
||||
|
||||
**There is no `-i` and no output path.** Input is an asset id and the output filename is ours. This
|
||||
is the invariant the whole security model rests on.
|
||||
|
||||
Filters are a default-deny allowlist. `movie`, `subtitles`, `frei0r`, `ladspa` and their relatives
|
||||
are refused because they read files or load native code; `drawtext` is allowed with its `textfile`
|
||||
and `fontfile` parameters rejected.
|
||||
@@ -0,0 +1,144 @@
|
||||
# Security model
|
||||
|
||||
av-club exposes ffmpeg, sharp and exiftool over HTTP. Done naively that is a remote shell. This
|
||||
document is the set of invariants that make it not one, and the reasoning for each — so that a future
|
||||
change can be checked against the reasoning rather than against a vibe.
|
||||
|
||||
**Read this before adding an endpoint, an option, or a filter.**
|
||||
|
||||
## The one invariant everything else rests on
|
||||
|
||||
> **The caller never supplies a path, a URL, or a protocol. Not for input, not for output, not
|
||||
> inside a filter argument, not anywhere.**
|
||||
|
||||
Every dangerous thing these tools can do is reached through a path or a URL. Remove the caller's
|
||||
ability to name one and the entire class disappears, which is what makes it safe to expose almost
|
||||
every *other* option these tools have.
|
||||
|
||||
Concretely: input is an **opaque asset id** issued by av-club for a file av-club received. Output is
|
||||
a path av-club chooses. Both are resolved server-side into paths under a job directory. Anything that
|
||||
looks like a path in a caller-supplied value is rejected, not sanitised.
|
||||
|
||||
### What that invariant is defending against
|
||||
|
||||
These all work by default, which is why "just pass the flags through" is not an option:
|
||||
|
||||
| Attack | How |
|
||||
|---|---|
|
||||
| Read any file the process can | `-i /etc/passwd`, then download the "converted" output |
|
||||
| Read a file the input filter never sees | `-f lavfi -i "movie=/home/user/.ssh/id_ed25519"` |
|
||||
| Read arbitrary paths via a demuxer | `-f concat -i list.txt` with a caller-controlled list |
|
||||
| SSRF into the private network | ffmpeg speaks `http:` — cloud metadata endpoints, internal services |
|
||||
| Write anywhere the process can | the output path is just an argument: `~/.ssh/authorized_keys` |
|
||||
| Load arbitrary native code | `frei0r`, `ladspa`, `lv2` filters load plugins by name from disk |
|
||||
|
||||
Note the second row especially. A guard that only inspected `-i` would pass it, because the read
|
||||
happens inside the filter graph. **This is why the rule is about values, not about which flag they
|
||||
arrived in.**
|
||||
|
||||
## Layer 1 — no shell, ever
|
||||
|
||||
Processes are spawned with an **argv array**, never a command string. There is no shell to quote for,
|
||||
so shell metacharacters are inert data rather than a parsing problem.
|
||||
|
||||
Anything that builds a command by string concatenation is a bug regardless of how well it escapes.
|
||||
|
||||
## Layer 2 — the caller names nothing
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Input** | An asset id (opaque, server-issued) or a multipart upload. Never a path or URL. |
|
||||
| **Output** | av-club picks the filename and directory. The caller picks the *format*, not where it lands. |
|
||||
| **Working dir** | One temp directory per job, removed when the job ends. |
|
||||
|
||||
Asset ids are validated against a strict pattern before they are used to build a path, and the
|
||||
resolved path is checked to still be inside the store afterwards. Two checks, because the first is
|
||||
about intent and the second is about what actually happened.
|
||||
|
||||
## Layer 3 — ffmpeg runs with protocols disabled
|
||||
|
||||
```
|
||||
-protocol_whitelist file (nothing else — no http, no concat, no subfile, no data)
|
||||
-f <explicit> inputs are demuxed as what we probed them to be, not auto-detected
|
||||
-nostdin ffmpeg never waits on stdin
|
||||
```
|
||||
|
||||
`-protocol_whitelist file` alone kills SSRF and the `concat:`/`subfile:` tricks outright. It is not
|
||||
a mitigation for a path leak — Layer 2 is — it is the second lock.
|
||||
|
||||
## Layer 4 — filters are allowlisted by name, and their arguments are checked
|
||||
|
||||
ffmpeg's filter graph is a language. Most of it is arithmetic on pixels and samples and is safe.
|
||||
A small, specific part of it touches the filesystem or loads code.
|
||||
|
||||
**Never allowed** — these read files or load native code, by design:
|
||||
|
||||
`movie`, `amovie`, `subtitles`, `ass`, `frei0r`, `frei0r_src`, `ladspa`, `lv2`, `sendcmd`,
|
||||
`asendcmd`, `vidstabtransform`, `libvmaf`, `openclsrc`
|
||||
|
||||
**Allowed, but with file-bearing parameters stripped**: `drawtext` is genuinely useful and carries
|
||||
`textfile=` and `fontfile=`. The filter is permitted; those two parameters are rejected. Text comes
|
||||
as a literal, fonts come from a fixed server-side set referenced by name.
|
||||
|
||||
The list is a **default-deny allowlist**, not a blocklist. A filter nobody has classified is
|
||||
refused. A blocklist would silently admit whatever the next ffmpeg release adds.
|
||||
|
||||
## Layer 5 — bounded work
|
||||
|
||||
Unbounded is a denial of service even when it is not a breach.
|
||||
|
||||
| Bound | Why |
|
||||
|---|---|
|
||||
| Upload size cap | Multipart bodies are buffered; an unbounded one is an OOM |
|
||||
| Wall-clock timeout per job, process killed on expiry | A crafted file can make an encoder run effectively forever |
|
||||
| Output dimension cap | `resize(50000, 50000)` is an allocation, not an image |
|
||||
| `limitInputPixels` on sharp | Decompression bombs: a 1 KB PNG that expands to 40 GB |
|
||||
| Concurrency cap | ffmpeg will use every core it is given; two callers should not be able to take the box down |
|
||||
| Job directory removed on completion **and** on failure | Otherwise disk fills with the failures |
|
||||
|
||||
## Layer 6 — the untrusted-parser problem, stated honestly
|
||||
|
||||
Everything above constrains what a caller may *ask for*. None of it helps with the fact that
|
||||
**ffmpeg and libvips are large C/C++ codebases parsing hostile binary input**, which is a
|
||||
memory-safety attack surface with a long CVE history — ImageTragick (CVE-2016-3714) and exiftool's
|
||||
DjVu RCE (CVE-2021-22204) are the famous ones, and they are not the last.
|
||||
|
||||
The honest mitigation is process isolation, and it is a *deployment* property rather than a code one:
|
||||
|
||||
- run the service as a dedicated unprivileged user that owns nothing else
|
||||
- no network egress from the process (with protocols disabled it has no reason to make one)
|
||||
- read-only filesystem apart from the job directory
|
||||
- ideally a container or `bubblewrap`/`nsjail` sandbox per job
|
||||
|
||||
**Status: not implemented yet.** It is written down here because it is the difference between "a
|
||||
bug in ffmpeg is a crash" and "a bug in ffmpeg is our whole machine", and because the thing most
|
||||
likely to happen otherwise is that it is never considered at all.
|
||||
|
||||
## exiftool is treated differently
|
||||
|
||||
exiftool is a Perl program with a plugin architecture and a history of code execution from
|
||||
**parsing** — not from its arguments. It is therefore used narrowly:
|
||||
|
||||
- **reading only**, with a fixed argument list, never caller-supplied options
|
||||
- never `-@` (argfile), never `-config`, never a user-supplied `-if` expression
|
||||
- stripping metadata is done by **sharp's re-encode**, not by exiftool
|
||||
|
||||
Sharp drops EXIF by default on re-encode, which is verified on every `scripts/verify.sh` run. That
|
||||
is a smaller, better-understood surface than asking exiftool to rewrite a file in place.
|
||||
|
||||
## Authentication
|
||||
|
||||
The service is not public and has no anonymous surface. Every endpoint requires a bearer token
|
||||
issued out of band. This is deliberately dull — it is an internal service between two things we
|
||||
operate, not a product.
|
||||
|
||||
**Even so, it must not be reachable from the internet.** Auth is the second lock; the first is not
|
||||
listening on a public interface.
|
||||
|
||||
## Reviewing a change against this document
|
||||
|
||||
- Does the caller name a path, a URL, or a protocol anywhere in the new surface? → **no**
|
||||
- Is a new ffmpeg filter classified in the allowlist, with a reason? → **required**
|
||||
- Is the new option's value constrained by type and range, or is it a free string? → **constrain it**
|
||||
- Can it produce unbounded work — time, memory, pixels, files? → **bound it**
|
||||
- Is a command being built as a string? → **it must not be**
|
||||
+491
@@ -0,0 +1,491 @@
|
||||
# sharp — the operation vocabulary
|
||||
|
||||
**Generated from `src/sharp/vocabulary.ts` by `scripts/gen-docs.ts`. Do not edit by hand —
|
||||
run `bun run docs`.**
|
||||
|
||||
Every operation is named what sharp names it and takes sharp's own arguments, so
|
||||
[sharp's documentation](https://sharp.pixelplumbing.com/api-operation) describes what each one
|
||||
*does*. This page describes what av-club *accepts*: the bounds, which exist because an unbounded
|
||||
number here is a denial of service rather than a picture.
|
||||
|
||||
Requests go to `POST /v1/sharp` — see [API.md](API.md) for the envelope.
|
||||
|
||||
```json
|
||||
{
|
||||
"input": "as_9f86d081884c7d659a2feaa0c55ad015",
|
||||
"options": { "failOn": "warning" },
|
||||
"pipeline": [
|
||||
{ "rotate": {} },
|
||||
{ "resize": { "width": 1200, "fit": "inside", "withoutEnlargement": true } }
|
||||
],
|
||||
"output": { "format": "avif", "options": { "quality": 55 } }
|
||||
}
|
||||
```
|
||||
|
||||
## Three things that catch people
|
||||
|
||||
**`rotate` with no angle applies the EXIF orientation.** Output is re-encoded and metadata is
|
||||
dropped, so a photo that looked upright in a phone's gallery arrives sideways on the web unless you
|
||||
include `{ "rotate": {} }`. This is the most commonly forgotten operation.
|
||||
|
||||
**`resize` enlarges by default.** A 300px source asked for 800px comes back 800px and soft. Pass
|
||||
`withoutEnlargement: true` unless you actually want that.
|
||||
|
||||
**Metadata is always stripped and there is no way to keep it.** `withMetadata` is deliberately not
|
||||
in this vocabulary — it is how a client's GPS coordinates end up published. Dimensions come back in
|
||||
the response instead.
|
||||
|
||||
## Input options
|
||||
|
||||
sharp's constructor options, passed as `options`.
|
||||
|
||||
| Parameter | Accepts |
|
||||
|---|---|
|
||||
| `failOn` | `none` · `truncated` · `error` · `warning`. How strict to be about a damaged file. sharp's default is 'warning'. |
|
||||
| `density` | number, 1–2400. DPI for vector input. |
|
||||
| `pages` | integer, -1–1000. Pages to read from a multi-page input. -1 for all. |
|
||||
| `page` | integer, 0–100000. First page to read. |
|
||||
| `animated` | boolean. Read all frames of an animated GIF/WebP. Equivalent to pages: -1. |
|
||||
| `ignoreIcc` | boolean. Ignore any embedded ICC profile. |
|
||||
| `autoOrient` | boolean. Apply EXIF orientation on load. See the note about rotate() in docs/SHARP.md. |
|
||||
| `sequentialRead` | boolean |
|
||||
| `level` | integer, 0–256. Level to extract from a multi-level input. |
|
||||
| `subifd` | integer, -1–100000. Subldeaf IFD to extract from an OME-TIFF. |
|
||||
|
||||
`limitInputPixels` is **not** accepted: it is the decompression-bomb guard and is set by the
|
||||
server. A 1 KB PNG can expand to tens of gigabytes.
|
||||
|
||||
## Operations
|
||||
|
||||
32 operations, run in the order given.
|
||||
|
||||
### `resize`
|
||||
|
||||
Resize to given dimensions.
|
||||
|
||||
| Parameter | Accepts |
|
||||
|---|---|
|
||||
| `width` | integer, 1–16383 |
|
||||
| `height` | integer, 1–16383 |
|
||||
| `fit` | `cover` · `contain` · `fill` · `inside` · `outside`. How the image fits the box. Default 'cover'. |
|
||||
| `position` | `centre` · `center` · `top` · `right top` · `right` · `right bottom` · `bottom` · `left bottom` · `left` · `left top` · `north` · `northeast` · `east` · `southeast` · `south` · `southwest` · `west` · `northwest` · `entropy` · `attention`. Which part survives when fit is 'cover'. 'attention' picks the busiest region. |
|
||||
| `background` | colour — a CSS string like `#ffffff`, or `{r, g, b, alpha}`. Fill colour when fit is 'contain'. |
|
||||
| `kernel` | `nearest` · `linear` · `cubic` · `mitchell` · `lanczos2` · `lanczos3` |
|
||||
| `withoutEnlargement` | boolean. Never scale UP. Almost always what you want — see docs/SHARP.md. |
|
||||
| `withoutReduction` | boolean. Never scale DOWN. |
|
||||
| `fastShrinkOnLoad` | boolean |
|
||||
|
||||
### `extend`
|
||||
|
||||
Add pixels to the edges.
|
||||
|
||||
| Parameter | Accepts |
|
||||
|---|---|
|
||||
| `top` | integer, 0–16383 |
|
||||
| `bottom` | integer, 0–16383 |
|
||||
| `left` | integer, 0–16383 |
|
||||
| `right` | integer, 0–16383 |
|
||||
| `extendWith` | `background` · `copy` · `repeat` · `mirror` |
|
||||
| `background` | colour — a CSS string like `#ffffff`, or `{r, g, b, alpha}` |
|
||||
|
||||
### `extract`
|
||||
|
||||
Crop to a region.
|
||||
|
||||
| Parameter | Accepts |
|
||||
|---|---|
|
||||
| `left` | integer, 0–16383 |
|
||||
| `top` | integer, 0–16383 |
|
||||
| `width` | integer, 1–16383 |
|
||||
| `height` | integer, 1–16383 |
|
||||
|
||||
### `trim`
|
||||
|
||||
Trim uniform borders.
|
||||
|
||||
| Parameter | Accepts |
|
||||
|---|---|
|
||||
| `background` | colour — a CSS string like `#ffffff`, or `{r, g, b, alpha}` |
|
||||
| `threshold` | number, 0–255 |
|
||||
| `lineArt` | boolean |
|
||||
|
||||
### `rotate`
|
||||
|
||||
Rotate. With no angle, applies the EXIF orientation — see docs/SHARP.md.
|
||||
|
||||
| Parameter | Accepts |
|
||||
|---|---|
|
||||
| `angle` | number, -3600–3600. Multiples of 90 are lossless; anything else needs a background. |
|
||||
| `background` | colour — a CSS string like `#ffffff`, or `{r, g, b, alpha}` |
|
||||
|
||||
### `flip`
|
||||
|
||||
Mirror vertically.
|
||||
|
||||
Takes `flip` directly — `{ "flip": { "flip": … } }`.
|
||||
|
||||
| Parameter | Accepts |
|
||||
|---|---|
|
||||
| `flip` | boolean |
|
||||
|
||||
### `flop`
|
||||
|
||||
Mirror horizontally.
|
||||
|
||||
Takes `flop` directly — `{ "flop": { "flop": … } }`.
|
||||
|
||||
| Parameter | Accepts |
|
||||
|---|---|
|
||||
| `flop` | boolean |
|
||||
|
||||
### `sharpen`
|
||||
|
||||
Sharpen.
|
||||
|
||||
| Parameter | Accepts |
|
||||
|---|---|
|
||||
| `sigma` | number, 0.000001–10 |
|
||||
| `m1` | number, 0–1000000 |
|
||||
| `m2` | number, 0–1000000 |
|
||||
| `x1` | number, 0–1000000 |
|
||||
| `y2` | number, 0–1000000 |
|
||||
| `y3` | number, 0–1000000 |
|
||||
|
||||
### `blur`
|
||||
|
||||
Gaussian blur.
|
||||
|
||||
Takes `sigma` directly — `{ "blur": { "sigma": … } }`.
|
||||
|
||||
| Parameter | Accepts |
|
||||
|---|---|
|
||||
| `sigma` | number, 0.3–1000 |
|
||||
|
||||
### `median`
|
||||
|
||||
Median filter — good for noise.
|
||||
|
||||
Takes `size` directly — `{ "median": { "size": … } }`.
|
||||
|
||||
| Parameter | Accepts |
|
||||
|---|---|
|
||||
| `size` | integer, 1–1000 |
|
||||
|
||||
### `dilate`
|
||||
|
||||
Morphological dilate.
|
||||
|
||||
Takes `width` directly — `{ "dilate": { "width": … } }`.
|
||||
|
||||
| Parameter | Accepts |
|
||||
|---|---|
|
||||
| `width` | integer, 1–1000 |
|
||||
|
||||
### `erode`
|
||||
|
||||
Morphological erode.
|
||||
|
||||
Takes `width` directly — `{ "erode": { "width": … } }`.
|
||||
|
||||
| Parameter | Accepts |
|
||||
|---|---|
|
||||
| `width` | integer, 1–1000 |
|
||||
|
||||
### `flatten`
|
||||
|
||||
Composite transparency onto a background.
|
||||
|
||||
| Parameter | Accepts |
|
||||
|---|---|
|
||||
| `background` | colour — a CSS string like `#ffffff`, or `{r, g, b, alpha}` |
|
||||
|
||||
### `unflatten`
|
||||
|
||||
Make pure white pixels transparent.
|
||||
|
||||
_Takes no options._
|
||||
|
||||
### `gamma`
|
||||
|
||||
Apply gamma correction.
|
||||
|
||||
| Parameter | Accepts |
|
||||
|---|---|
|
||||
| `gamma` | number, 1–3 |
|
||||
| `gammaOut` | number, 1–3 |
|
||||
|
||||
### `negate`
|
||||
|
||||
Invert.
|
||||
|
||||
| Parameter | Accepts |
|
||||
|---|---|
|
||||
| `alpha` | boolean. Also invert the alpha channel. |
|
||||
|
||||
### `normalise`
|
||||
|
||||
Stretch the histogram to full range.
|
||||
|
||||
| Parameter | Accepts |
|
||||
|---|---|
|
||||
| `lower` | number, 0–99 |
|
||||
| `upper` | number, 1–100 |
|
||||
|
||||
### `clahe`
|
||||
|
||||
Contrast-limited adaptive histogram equalisation.
|
||||
|
||||
| Parameter | Accepts |
|
||||
|---|---|
|
||||
| `width` | integer, 1–16383 |
|
||||
| `height` | integer, 1–16383 |
|
||||
| `maxSlope` | integer, 0–100 |
|
||||
|
||||
### `convolve`
|
||||
|
||||
Apply a convolution kernel.
|
||||
|
||||
| Parameter | Accepts |
|
||||
|---|---|
|
||||
| `width` | integer, 1–100 |
|
||||
| `height` | integer, 1–100 |
|
||||
| `kernel` | array of 1–10000 numbers, each -1000–1000 |
|
||||
| `scale` | number, -10000–10000 |
|
||||
| `offset` | number, -10000–10000 |
|
||||
|
||||
### `threshold`
|
||||
|
||||
Reduce to black and white at a cutoff.
|
||||
|
||||
| Parameter | Accepts |
|
||||
|---|---|
|
||||
| `threshold` | integer, 0–255 |
|
||||
| `greyscale` | boolean |
|
||||
|
||||
### `linear`
|
||||
|
||||
Multiply and add: out = in * a + b.
|
||||
|
||||
| Parameter | Accepts |
|
||||
|---|---|
|
||||
| `a` | array of 1–4 numbers, each -1000–1000 |
|
||||
| `b` | array of 1–4 numbers, each -1000–1000 |
|
||||
|
||||
### `recomb`
|
||||
|
||||
Recombine channels with a 3x3 matrix.
|
||||
|
||||
Takes `matrix` directly — `{ "recomb": { "matrix": … } }`.
|
||||
|
||||
| Parameter | Accepts |
|
||||
|---|---|
|
||||
| `matrix` | array of 9 numbers, each -100–100 |
|
||||
|
||||
### `modulate`
|
||||
|
||||
Adjust brightness, saturation, hue and lightness.
|
||||
|
||||
| Parameter | Accepts |
|
||||
|---|---|
|
||||
| `brightness` | number, 0–100 |
|
||||
| `saturation` | number, 0–100 |
|
||||
| `hue` | number, -3600–3600. Degrees. |
|
||||
| `lightness` | number, -100–100 |
|
||||
|
||||
### `tint`
|
||||
|
||||
Tint with a colour.
|
||||
|
||||
Takes `color` directly — `{ "tint": { "color": … } }`.
|
||||
|
||||
| Parameter | Accepts |
|
||||
|---|---|
|
||||
| `color` | colour — a CSS string like `#ffffff`, or `{r, g, b, alpha}` |
|
||||
|
||||
### `greyscale`
|
||||
|
||||
Convert to greyscale.
|
||||
|
||||
Takes `greyscale` directly — `{ "greyscale": { "greyscale": … } }`.
|
||||
|
||||
| Parameter | Accepts |
|
||||
|---|---|
|
||||
| `greyscale` | boolean |
|
||||
|
||||
### `toColourspace`
|
||||
|
||||
Convert to a colourspace.
|
||||
|
||||
Takes `colourspace` directly — `{ "toColourspace": { "colourspace": … } }`.
|
||||
|
||||
| Parameter | Accepts |
|
||||
|---|---|
|
||||
| `colourspace` | `srgb` · `rgb` · `cmyk` · `lab` · `b-w` · `hsv` · `scrgb` · `lch` · `grey16` · `rgb16` |
|
||||
|
||||
### `pipelineColourspace`
|
||||
|
||||
Set the colourspace operations are performed in.
|
||||
|
||||
Takes `colourspace` directly — `{ "pipelineColourspace": { "colourspace": … } }`.
|
||||
|
||||
| Parameter | Accepts |
|
||||
|---|---|
|
||||
| `colourspace` | `srgb` · `rgb` · `cmyk` · `lab` · `b-w` · `hsv` · `scrgb` · `lch` · `grey16` · `rgb16` |
|
||||
|
||||
### `removeAlpha`
|
||||
|
||||
Drop the alpha channel.
|
||||
|
||||
_Takes no options._
|
||||
|
||||
### `ensureAlpha`
|
||||
|
||||
Add an alpha channel if absent.
|
||||
|
||||
Takes `alpha` directly — `{ "ensureAlpha": { "alpha": … } }`.
|
||||
|
||||
| Parameter | Accepts |
|
||||
|---|---|
|
||||
| `alpha` | number, 0–1 |
|
||||
|
||||
### `extractChannel`
|
||||
|
||||
Keep one channel.
|
||||
|
||||
Takes `channel` directly — `{ "extractChannel": { "channel": … } }`.
|
||||
|
||||
| Parameter | Accepts |
|
||||
|---|---|
|
||||
| `channel` | `red` · `green` · `blue` · `alpha` · `0` · `1` · `2` · `3` |
|
||||
|
||||
### `bandbool`
|
||||
|
||||
Boolean-combine all channels into one.
|
||||
|
||||
Takes `operator` directly — `{ "bandbool": { "operator": … } }`.
|
||||
|
||||
| Parameter | Accepts |
|
||||
|---|---|
|
||||
| `operator` | `and` · `or` · `eor` |
|
||||
|
||||
### `composite`
|
||||
|
||||
Overlay another asset. `input` is an asset id, never a path.
|
||||
|
||||
| Parameter | Accepts |
|
||||
|---|---|
|
||||
| `input` | **asset id** (never a path). The asset to overlay. Must already be uploaded. |
|
||||
| `blend` | `clear` · `source` · `over` · `in` · `out` · `atop` · `dest` · `dest-over` · `dest-in` · `dest-out` · `dest-atop` · `xor` · `add` · `saturate` · `multiply` · `screen` · `overlay` · `darken` · `lighten` · `colour-dodge` · `color-dodge` · `colour-burn` · `color-burn` · `hard-light` · `soft-light` · `difference` · `exclusion` |
|
||||
| `gravity` | `centre` · `center` · `north` · `northeast` · `east` · `southeast` · `south` · `southwest` · `west` · `northwest` |
|
||||
| `top` | integer, 0–16383 |
|
||||
| `left` | integer, 0–16383 |
|
||||
| `tile` | boolean |
|
||||
| `premultiplied` | boolean |
|
||||
|
||||
## Output formats
|
||||
|
||||
Given as `output.format`, with `output.options` carrying the encoder settings. Defaults to `webp`.
|
||||
|
||||
### `jpeg`
|
||||
|
||||
JPEG.
|
||||
|
||||
| Parameter | Accepts |
|
||||
|---|---|
|
||||
| `quality` | integer, 1–100 |
|
||||
| `progressive` | boolean |
|
||||
| `chromaSubsampling` | `4:4:4` · `4:2:2` · `4:2:0` |
|
||||
| `optimiseCoding` | boolean |
|
||||
| `mozjpeg` | boolean. Turns on a bundle of mozjpeg settings at once. |
|
||||
| `trellisQuantisation` | boolean |
|
||||
| `overshootDeringing` | boolean |
|
||||
| `optimiseScans` | boolean |
|
||||
|
||||
### `png`
|
||||
|
||||
PNG.
|
||||
|
||||
| Parameter | Accepts |
|
||||
|---|---|
|
||||
| `progressive` | boolean |
|
||||
| `compressionLevel` | integer, 0–9 |
|
||||
| `adaptiveFiltering` | boolean |
|
||||
| `palette` | boolean. Quantise to a palette — much smaller for flat graphics. |
|
||||
| `quality` | integer, 0–100 |
|
||||
| `effort` | integer, 1–10 |
|
||||
| `colours` | integer, 2–256 |
|
||||
| `dither` | number, 0–1 |
|
||||
|
||||
### `webp`
|
||||
|
||||
WebP.
|
||||
|
||||
| Parameter | Accepts |
|
||||
|---|---|
|
||||
| `quality` | integer, 1–100 |
|
||||
| `alphaQuality` | integer, 0–100 |
|
||||
| `lossless` | boolean |
|
||||
| `nearLossless` | boolean |
|
||||
| `smartSubsample` | boolean |
|
||||
| `preset` | `default` · `photo` · `picture` · `drawing` · `icon` · `text` |
|
||||
| `effort` | integer, 0–6 |
|
||||
| `loop` | integer, 0–100000 |
|
||||
| `minSize` | boolean |
|
||||
| `mixed` | boolean |
|
||||
|
||||
### `avif`
|
||||
|
||||
AVIF. Smaller than WebP at the same quality, slower to encode.
|
||||
|
||||
| Parameter | Accepts |
|
||||
|---|---|
|
||||
| `quality` | integer, 1–100 |
|
||||
| `lossless` | boolean |
|
||||
| `effort` | integer, 0–9. Higher is smaller and much slower. |
|
||||
| `chromaSubsampling` | `4:4:4` · `4:2:0` |
|
||||
| `bitdepth` | `8` · `10` · `12` |
|
||||
|
||||
### `tiff`
|
||||
|
||||
TIFF.
|
||||
|
||||
| Parameter | Accepts |
|
||||
|---|---|
|
||||
| `quality` | integer, 1–100 |
|
||||
| `compression` | `none` · `jpeg` · `deflate` · `packbits` · `ccittfax4` · `lzw` · `webp` · `zstd` · `jp2k` |
|
||||
| `predictor` | `none` · `horizontal` · `float` |
|
||||
| `pyramid` | boolean |
|
||||
| `tile` | boolean |
|
||||
| `tileWidth` | integer, 1–16383 |
|
||||
| `tileHeight` | integer, 1–16383 |
|
||||
| `xres` | number, 0.01–100000 |
|
||||
| `yres` | number, 0.01–100000 |
|
||||
| `resolutionUnit` | `inch` · `cm` |
|
||||
| `bitdepth` | `1` · `2` · `4` · `8` |
|
||||
|
||||
### `gif`
|
||||
|
||||
GIF.
|
||||
|
||||
| Parameter | Accepts |
|
||||
|---|---|
|
||||
| `reuse` | boolean |
|
||||
| `progressive` | boolean |
|
||||
| `colours` | integer, 2–256 |
|
||||
| `effort` | integer, 1–10 |
|
||||
| `dither` | number, 0–1 |
|
||||
| `interFrameMaxError` | number, 0–32 |
|
||||
| `interPaletteMaxError` | number, 0–256 |
|
||||
| `loop` | integer, 0–100000 |
|
||||
|
||||
### `jp2`
|
||||
|
||||
JPEG 2000.
|
||||
|
||||
| Parameter | Accepts |
|
||||
|---|---|
|
||||
| `quality` | integer, 1–100 |
|
||||
| `lossless` | boolean |
|
||||
| `tileWidth` | integer, 1–16383 |
|
||||
| `tileHeight` | integer, 1–16383 |
|
||||
| `chromaSubsampling` | `4:4:4` · `4:2:2` · `4:2:0` |
|
||||
Reference in New Issue
Block a user