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
@@ -0,0 +1,17 @@
|
||||
# Copy to .env and fill in. The service refuses to start without AV_CLUB_TOKEN.
|
||||
|
||||
# The bearer token every request must carry. Generate with:
|
||||
# openssl rand -hex 32
|
||||
AV_CLUB_TOKEN=
|
||||
|
||||
AV_CLUB_PORT=3020
|
||||
|
||||
# Where stored assets live. Deliberately NOT inside the repo: a deploy that replaces the working
|
||||
# tree would otherwise delete every original ever uploaded, silently.
|
||||
# AV_CLUB_ASSETS_DIR=/var/lib/av-club/assets
|
||||
|
||||
# Limits. Each exists because its absence is a denial of service — see docs/SECURITY.md.
|
||||
# AV_CLUB_MAX_UPLOAD_BYTES=536870912
|
||||
# AV_CLUB_JOB_TIMEOUT_MS=120000
|
||||
# AV_CLUB_MAX_OUTPUT_DIMENSION=16383
|
||||
# AV_CLUB_MAX_CONCURRENT_JOBS=2
|
||||
@@ -6,27 +6,50 @@ People upload photos and videos straight off a phone or a camera: a 12 MB HEIC,
|
||||
Safari refuses to play, a JPEG carrying the GPS coordinates of the room it was shot in. None of that
|
||||
belongs on a page as-is. This is the thing in between.
|
||||
|
||||
It is an HTTP service that exposes as much of **sharp**, **ffmpeg** and **ffprobe** as can be exposed
|
||||
safely, keeping each tool's own vocabulary — sharp operations are named what sharp names them,
|
||||
ffprobe fields come back spelled the way ffprobe spells them. If you know the tool, you know the API.
|
||||
|
||||
What it will not let you do is name a file. That single restriction is what makes the rest safe to
|
||||
open up; see [`docs/SECURITY.md`](docs/SECURITY.md).
|
||||
|
||||
## Status
|
||||
|
||||
**Early.** Right now this repo contains the tooling requirements, an installer, and the
|
||||
documentation of what the pipeline is meant to become. The processing code is not written yet.
|
||||
Assets, probing and the full sharp surface work. **ffmpeg is designed but not built** — that is the
|
||||
next piece.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `scripts/install.sh` | Brings a machine to the state this needs, then verifies it |
|
||||
| `scripts/verify.sh` | Checks the tooling by running it — usable standalone |
|
||||
| `docs/TOOLING.md` | What each tool is for, and the two things that bite |
|
||||
| `docs/PIPELINE.md` | What the pipeline is supposed to do, and what is still undecided |
|
||||
| [`docs/API.md`](docs/API.md) | The HTTP surface — start here if you are calling it |
|
||||
| [`docs/SHARP.md`](docs/SHARP.md) | Every sharp operation and its bounds. Generated from the validator, so it cannot drift |
|
||||
| [`docs/SECURITY.md`](docs/SECURITY.md) | The threat model and the invariants. Read before adding an endpoint |
|
||||
| [`docs/TOOLING.md`](docs/TOOLING.md) | What is installed, and the two things that bite |
|
||||
| [`docs/PIPELINE.md`](docs/PIPELINE.md) | What this is for, and what is still undecided |
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
./scripts/install.sh # system packages + bun deps, then verifies
|
||||
./scripts/verify.sh # verification on its own
|
||||
cp .env.example .env # then set AV_CLUB_TOKEN
|
||||
bun dev
|
||||
```
|
||||
|
||||
Verified on Ubuntu 24.04. The installer says so rather than guessing if it finds something else.
|
||||
|
||||
```bash
|
||||
# upload something, then resize it
|
||||
curl -X POST localhost:3020/v1/assets -H "Authorization: Bearer $AV_CLUB_TOKEN" -F file=@photo.jpg
|
||||
|
||||
curl -X POST localhost:3020/v1/sharp -H "Authorization: Bearer $AV_CLUB_TOKEN" \
|
||||
-H 'Content-Type: application/json' -d '{
|
||||
"input": "as_…",
|
||||
"pipeline": [{ "rotate": {} }, { "resize": { "width": 1200, "withoutEnlargement": true } }],
|
||||
"output": { "format": "avif", "options": { "quality": 55 } }
|
||||
}'
|
||||
```
|
||||
|
||||
`bun run verify` checks the tooling; `bun run docs` regenerates `docs/SHARP.md` from the validator.
|
||||
|
||||
## Why this is its own repo
|
||||
|
||||
**It has system dependencies a web app should not have to assume.** ffmpeg, ImageMagick, libvips and
|
||||
|
||||
+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` |
|
||||
@@ -5,6 +5,9 @@
|
||||
"type": "module",
|
||||
"description": "Turning whatever someone uploads into assets a website can actually serve",
|
||||
"scripts": {
|
||||
"dev": "bun --hot src/server.ts",
|
||||
"start": "bun src/server.ts",
|
||||
"docs": "bun run ./scripts/gen-docs.ts",
|
||||
"install:tools": "./scripts/install.sh",
|
||||
"verify": "./scripts/verify.sh"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
#!/usr/bin/env bun
|
||||
// Generate docs/SHARP.md from src/sharp/vocabulary.ts.
|
||||
//
|
||||
// The vocabulary table is what the validator enforces. Writing the reference by hand would mean two
|
||||
// descriptions of one thing, and the doc would quietly stop being true the first time a bound
|
||||
// changed. This makes the drift structurally impossible instead of a discipline problem.
|
||||
//
|
||||
// Run: bun run docs
|
||||
|
||||
import { INPUT_OPTIONS, OPERATIONS, OUTPUT_FORMATS, type Param } from "../src/sharp/vocabulary";
|
||||
import { resolve, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
|
||||
function describeParam(spec: Param): string {
|
||||
const base = (() => {
|
||||
switch (spec.kind) {
|
||||
case "int": return `integer, ${spec.min}–${spec.max}`;
|
||||
case "number": return `number, ${spec.min}–${spec.max}`;
|
||||
case "bool": return "boolean";
|
||||
case "enum": return spec.values.map((v) => `\`${v}\``).join(" · ");
|
||||
case "color": return "colour — a CSS string like `#ffffff`, or `{r, g, b, alpha}`";
|
||||
case "numberArray": return `array of ${spec.minLength === spec.maxLength ? spec.minLength : `${spec.minLength}–${spec.maxLength}`} numbers, each ${spec.min}–${spec.max}`;
|
||||
case "assetId": return "**asset id** (never a path)";
|
||||
case "string": return `string, up to ${spec.maxLength} chars`;
|
||||
}
|
||||
})();
|
||||
return spec.note ? `${base}. ${spec.note}` : base;
|
||||
}
|
||||
|
||||
function paramTable(params: Record<string, Param>): string {
|
||||
const keys = Object.keys(params);
|
||||
if (!keys.length) return "_Takes no options._\n";
|
||||
|
||||
return [
|
||||
"| Parameter | Accepts |",
|
||||
"|---|---|",
|
||||
...keys.map((key) => `| \`${key}\` | ${describeParam(params[key]!)} |`),
|
||||
"",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
const lines: string[] = [
|
||||
"# 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`.",
|
||||
"",
|
||||
paramTable(INPUT_OPTIONS),
|
||||
"`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",
|
||||
"",
|
||||
`${Object.keys(OPERATIONS).length} operations, run in the order given.`,
|
||||
"",
|
||||
];
|
||||
|
||||
for (const [name, spec] of Object.entries(OPERATIONS)) {
|
||||
lines.push(`### \`${name}\``, "", spec.describe, "");
|
||||
if (spec.positional) {
|
||||
lines.push(
|
||||
`Takes \`${spec.positional}\` directly — \`{ "${name}": { "${spec.positional}": … } }\`.`,
|
||||
"",
|
||||
);
|
||||
}
|
||||
lines.push(paramTable(spec.params));
|
||||
}
|
||||
|
||||
lines.push(
|
||||
"## Output formats",
|
||||
"",
|
||||
"Given as `output.format`, with `output.options` carrying the encoder settings. Defaults to `webp`.",
|
||||
"",
|
||||
);
|
||||
|
||||
for (const [name, spec] of Object.entries(OUTPUT_FORMATS)) {
|
||||
lines.push(`### \`${name}\``, "", spec.describe, "", paramTable(spec.params));
|
||||
}
|
||||
|
||||
const path = resolve(ROOT, "docs/SHARP.md");
|
||||
await Bun.write(path, lines.join("\n"));
|
||||
console.log(
|
||||
`[gen-docs] docs/SHARP.md — ${Object.keys(OPERATIONS).length} operations, ${Object.keys(OUTPUT_FORMATS).length} output formats`,
|
||||
);
|
||||
@@ -0,0 +1,149 @@
|
||||
import { rm } from "node:fs/promises";
|
||||
import { join, resolve, sep } from "node:path";
|
||||
import sharp from "sharp";
|
||||
import { ASSETS_DIR, MAX_INPUT_PIXELS } from "../config";
|
||||
import { invalid, notFound, unprocessable } from "../errors";
|
||||
import { ffprobe } from "../probe/ffprobe";
|
||||
|
||||
/**
|
||||
* The asset store — and the place docs/SECURITY.md's central invariant is enforced.
|
||||
*
|
||||
* A caller never supplies a path. They supply an ID we issued, and this module is the only thing
|
||||
* that turns an ID into a path. Everything downstream receives a path this module produced.
|
||||
*/
|
||||
|
||||
export type AssetKind = "image" | "video" | "audio";
|
||||
|
||||
export type Asset = {
|
||||
id: string;
|
||||
bytes: number;
|
||||
sha256: string;
|
||||
kind: AssetKind;
|
||||
format: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
durationSeconds?: number;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
/** `as_` plus 32 lowercase hex. Anchored, fixed length, no separators — nothing here can traverse. */
|
||||
const ID_PATTERN = /^as_[0-9a-f]{32}$/;
|
||||
|
||||
export function newAssetId(): string {
|
||||
return `as_${crypto.randomUUID().replaceAll("-", "")}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an ID to its directory, or throw.
|
||||
*
|
||||
* Two checks, deliberately, because they answer different questions:
|
||||
*
|
||||
* 1. Does the ID match the pattern we issue? — about the caller's INTENT
|
||||
* 2. Is the resolved path still inside the store? — about what actually HAPPENED
|
||||
*
|
||||
* The second is not redundant. It is the one that still holds if the pattern is ever loosened, if
|
||||
* ASSETS_DIR contains a symlink, or if some future refactor builds the path differently. Cheap, and
|
||||
* the failure it prevents is "read any file on the machine".
|
||||
*/
|
||||
function assetDir(id: string): string {
|
||||
if (!ID_PATTERN.test(id)) {
|
||||
throw invalid(`Not a valid asset id: ${JSON.stringify(id.slice(0, 64))}`, "id");
|
||||
}
|
||||
|
||||
const dir = resolve(join(ASSETS_DIR, id));
|
||||
if (dir !== join(ASSETS_DIR, id) || !dir.startsWith(ASSETS_DIR + sep)) {
|
||||
throw invalid("Not a valid asset id", "id");
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
|
||||
export const contentPath = (id: string): string => join(assetDir(id), "content");
|
||||
const metaPath = (id: string): string => join(assetDir(id), "meta.json");
|
||||
|
||||
export async function readAsset(id: string): Promise<Asset> {
|
||||
const file = Bun.file(metaPath(id));
|
||||
if (!(await file.exists())) throw notFound();
|
||||
return (await file.json()) as Asset;
|
||||
}
|
||||
|
||||
export async function assetExists(id: string): Promise<boolean> {
|
||||
return Bun.file(metaPath(id)).exists();
|
||||
}
|
||||
|
||||
export async function deleteAsset(id: string): Promise<void> {
|
||||
await rm(assetDir(id), { recursive: true, force: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Identify a file by INSPECTING IT, never from its name or the Content-Type a browser attached.
|
||||
*
|
||||
* Order matters: ffprobe will happily report a PNG as a video stream in an `image2` container, so
|
||||
* sharp is asked first and images are settled before ffprobe is consulted at all.
|
||||
*/
|
||||
async function identify(path: string): Promise<Pick<Asset, "kind" | "format" | "width" | "height" | "durationSeconds">> {
|
||||
try {
|
||||
const meta = await sharp(path, { limitInputPixels: MAX_INPUT_PIXELS }).metadata();
|
||||
if (meta.format) {
|
||||
return { kind: "image", format: meta.format, width: meta.width, height: meta.height };
|
||||
}
|
||||
} catch {
|
||||
// Not an image sharp can read. Fall through — it may well be a video.
|
||||
}
|
||||
|
||||
const probe = await ffprobe(path).catch(() => null);
|
||||
const streams = probe?.streams ?? [];
|
||||
|
||||
const video = streams.find((s) => s.codec_type === "video");
|
||||
const audio = streams.find((s) => s.codec_type === "audio");
|
||||
const duration = probe?.format?.duration ? Number(probe.format.duration) : undefined;
|
||||
const format = probe?.format?.format_name?.split(",")[0] ?? "unknown";
|
||||
|
||||
if (video) {
|
||||
return {
|
||||
kind: "video",
|
||||
format,
|
||||
width: typeof video.width === "number" ? video.width : undefined,
|
||||
height: typeof video.height === "number" ? video.height : undefined,
|
||||
durationSeconds: duration,
|
||||
};
|
||||
}
|
||||
if (audio) return { kind: "audio", format, durationSeconds: duration };
|
||||
|
||||
throw unprocessable(
|
||||
"That file could not be read as an image, video or audio file. It may be corrupt, or it may not be media at all.",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Store bytes and return the asset.
|
||||
*
|
||||
* The ID is issued here, so a caller can never influence where a file lands. Identification runs
|
||||
* AFTER the write because both sharp and ffprobe want a path — a file that turns out not to be
|
||||
* media is written and then removed rather than being buffered twice.
|
||||
*/
|
||||
export async function putAsset(bytes: ArrayBuffer | Uint8Array): Promise<Asset> {
|
||||
const id = newAssetId();
|
||||
const path = contentPath(id);
|
||||
|
||||
await Bun.write(path, bytes);
|
||||
|
||||
try {
|
||||
const identified = await identify(path);
|
||||
const buffer = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
|
||||
|
||||
const asset: Asset = {
|
||||
id,
|
||||
bytes: buffer.byteLength,
|
||||
sha256: new Bun.CryptoHasher("sha256").update(buffer).digest("hex"),
|
||||
createdAt: new Date().toISOString(),
|
||||
...identified,
|
||||
};
|
||||
|
||||
await Bun.write(metaPath(id), JSON.stringify(asset, null, 2));
|
||||
return asset;
|
||||
} catch (error) {
|
||||
// Do not leave the bytes of something we refused sitting in the store.
|
||||
await deleteAsset(id).catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { resolve } from "node:path";
|
||||
|
||||
/**
|
||||
* Configuration, read once at boot.
|
||||
*
|
||||
* Every limit here exists because its absence is a denial of service — see docs/SECURITY.md,
|
||||
* "Layer 5 — bounded work". They are configurable so a deployment can tighten them, not so a
|
||||
* deployment can remove them: each has a floor enforced below.
|
||||
*/
|
||||
|
||||
function num(key: string, fallback: number, min: number): number {
|
||||
const raw = process.env[key];
|
||||
if (raw === undefined) return fallback;
|
||||
|
||||
const value = Number(raw);
|
||||
if (!Number.isFinite(value)) {
|
||||
throw new Error(`${key} must be a number, got ${JSON.stringify(raw)}`);
|
||||
}
|
||||
if (value < min) {
|
||||
throw new Error(`${key} must be at least ${min}, got ${value}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export const PORT = num("AV_CLUB_PORT", 3020, 1);
|
||||
|
||||
/**
|
||||
* Where assets live. Outside the repo by default, and deliberately not under any directory a deploy
|
||||
* would replace — losing every stored asset because the working tree was refreshed is the kind of
|
||||
* failure that is only noticed later, by which point the originals are gone.
|
||||
*/
|
||||
export const ASSETS_DIR = resolve(process.env.AV_CLUB_ASSETS_DIR ?? `${process.env.HOME}/.local/share/av-club/assets`);
|
||||
|
||||
/** Multipart bodies are buffered whole. Without a cap, one request is an out-of-memory. */
|
||||
export const MAX_UPLOAD_BYTES = num("AV_CLUB_MAX_UPLOAD_BYTES", 512 * 1024 * 1024, 1024);
|
||||
|
||||
/** A crafted file can make an encoder run effectively forever. The process is killed at this point. */
|
||||
export const JOB_TIMEOUT_MS = num("AV_CLUB_JOB_TIMEOUT_MS", 120_000, 1_000);
|
||||
|
||||
/**
|
||||
* Decompression bombs: a 1 KB PNG can expand to tens of gigabytes. This is sharp's own
|
||||
* `limitInputPixels`, defaulted to its documented default (0x3FFF × 0x3FFF) rather than to `false`.
|
||||
*/
|
||||
export const MAX_INPUT_PIXELS = num("AV_CLUB_MAX_INPUT_PIXELS", 0x3fff * 0x3fff, 1);
|
||||
|
||||
/** `resize(50000, 50000)` is an allocation request, not an image. */
|
||||
export const MAX_OUTPUT_DIMENSION = num("AV_CLUB_MAX_OUTPUT_DIMENSION", 16_383, 1);
|
||||
|
||||
/** ffmpeg will take every core it is given. Two callers should not be able to take the box down. */
|
||||
export const MAX_CONCURRENT_JOBS = num("AV_CLUB_MAX_CONCURRENT_JOBS", 2, 1);
|
||||
|
||||
/**
|
||||
* The bearer token every request must carry.
|
||||
*
|
||||
* A FUNCTION, not a constant evaluated at import. The limits above are plain data that anything may
|
||||
* read — the doc generator imports the sharp vocabulary, which imports MAX_OUTPUT_DIMENSION from
|
||||
* here — and a module-scope throw meant that generating documentation required a production secret.
|
||||
*
|
||||
* The check itself still happens at boot: server.ts calls this before it listens, so a missing token
|
||||
* is a refusal to start rather than a service that quietly accepts everyone. There is no
|
||||
* "unauthenticated by default" mode; this process runs decoders over untrusted bytes.
|
||||
*/
|
||||
export function requireToken(): string {
|
||||
const value = process.env.AV_CLUB_TOKEN;
|
||||
if (!value || value.length < 16) {
|
||||
throw new Error(
|
||||
"AV_CLUB_TOKEN must be set and at least 16 characters. Generate one with: openssl rand -hex 32",
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* One error envelope for every failure, because a client parses failures the same way it parses
|
||||
* successes. A route that answers with a bare string does not return the wrong shape — it breaks
|
||||
* the parse. `error` is always an object.
|
||||
*/
|
||||
|
||||
export const ERROR_STATUS = {
|
||||
invalid_request: 400,
|
||||
unauthorized: 401,
|
||||
not_found: 404,
|
||||
payload_too_large: 413,
|
||||
unprocessable: 422,
|
||||
timeout: 504,
|
||||
server_error: 500,
|
||||
} as const;
|
||||
|
||||
export type ErrorType = keyof typeof ERROR_STATUS;
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
readonly type: ErrorType,
|
||||
message: string,
|
||||
/** Dotted path to the offending field, e.g. `pipeline[1].resize.width`. */
|
||||
readonly param?: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
}
|
||||
|
||||
toResponse(): Response {
|
||||
return Response.json(
|
||||
{ error: { message: this.message, type: this.type, param: this.param ?? null } },
|
||||
{ status: ERROR_STATUS[this.type] },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const invalid = (message: string, param?: string) => new ApiError("invalid_request", message, param);
|
||||
export const notFound = (message = "No such asset") => new ApiError("not_found", message);
|
||||
export const unprocessable = (message: string, param?: string) => new ApiError("unprocessable", message, param);
|
||||
|
||||
/**
|
||||
* Turn anything thrown into a response.
|
||||
*
|
||||
* An unrecognised error is logged and answered with a generic 500 — the message may name a path or
|
||||
* quote file contents, and neither belongs in a response body.
|
||||
*/
|
||||
export function toErrorResponse(error: unknown): Response {
|
||||
if (error instanceof ApiError) return error.toResponse();
|
||||
|
||||
console.error("[av-club] unhandled:", error);
|
||||
return Response.json(
|
||||
{ error: { message: "Internal Server Error", type: "server_error", param: null } },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { JOB_TIMEOUT_MS } from "../config";
|
||||
import { unprocessable } from "../errors";
|
||||
|
||||
/**
|
||||
* ffprobe, with its own field names passed through untouched.
|
||||
*
|
||||
* `codec_name`, `r_frame_rate`, `nb_frames` and the rest reach the caller exactly as ffprobe emits
|
||||
* them. Renaming them into some house style would mean maintaining a translation table and would
|
||||
* make ffprobe's own documentation stop applying to this API, which is the opposite of the point.
|
||||
*/
|
||||
|
||||
export type FfprobeStream = {
|
||||
index: number;
|
||||
codec_type?: string;
|
||||
codec_name?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export type FfprobeResult = {
|
||||
format?: { format_name?: string; duration?: string; bit_rate?: string; [key: string]: unknown };
|
||||
streams?: FfprobeStream[];
|
||||
};
|
||||
|
||||
export async function ffprobe(path: string): Promise<FfprobeResult> {
|
||||
// Spawned as an argv ARRAY, never a command string — there is no shell here to quote for, so a
|
||||
// filename containing shell metacharacters is inert data. See docs/SECURITY.md, Layer 1.
|
||||
//
|
||||
// `-protocol_whitelist file` is the second lock: `path` is always one this process constructed,
|
||||
// but if that ever stops being true, ffprobe still cannot be talked into speaking http.
|
||||
const proc = Bun.spawn(
|
||||
[
|
||||
"ffprobe",
|
||||
"-v", "error",
|
||||
"-protocol_whitelist", "file",
|
||||
"-print_format", "json",
|
||||
"-show_format",
|
||||
"-show_streams",
|
||||
path,
|
||||
],
|
||||
{ stdout: "pipe", stderr: "pipe", stdin: "ignore" },
|
||||
);
|
||||
|
||||
const timer = setTimeout(() => proc.kill(), JOB_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const [stdout, stderr, code] = await Promise.all([
|
||||
new Response(proc.stdout).text(),
|
||||
new Response(proc.stderr).text(),
|
||||
proc.exited,
|
||||
]);
|
||||
|
||||
if (code !== 0) {
|
||||
throw unprocessable(`Could not read that file: ${stderr.trim().split("\n").at(-1) ?? "ffprobe failed"}`);
|
||||
}
|
||||
|
||||
return JSON.parse(stdout) as FfprobeResult;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
+188
@@ -0,0 +1,188 @@
|
||||
import { timingSafeEqual } from "node:crypto";
|
||||
import { ASSETS_DIR, MAX_CONCURRENT_JOBS, MAX_UPLOAD_BYTES, PORT, requireToken } from "./config";
|
||||
import { ApiError, invalid, notFound, toErrorResponse } from "./errors";
|
||||
import { contentPath, deleteAsset, putAsset, readAsset } from "./assets/store";
|
||||
import { ffprobe } from "./probe/ffprobe";
|
||||
import { runSharp, type SharpRequest } from "./sharp/run";
|
||||
import sharp from "sharp";
|
||||
|
||||
/**
|
||||
* av-club — ffmpeg, sharp and ffprobe over HTTP.
|
||||
*
|
||||
* The security model is in docs/SECURITY.md and is not optional reading: the caller never names a
|
||||
* path, and that single invariant is what makes it safe to expose the rest of these tools' surface.
|
||||
*/
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
// Read once, at boot, so a missing token stops the process here rather than at the first request.
|
||||
const TOKEN = requireToken();
|
||||
|
||||
/**
|
||||
* Constant-time bearer check.
|
||||
*
|
||||
* `===` on secrets leaks their length and their common prefix through timing. The cost of doing it
|
||||
* properly is three lines.
|
||||
*/
|
||||
function authorised(request: Request): boolean {
|
||||
const header = request.headers.get("authorization");
|
||||
if (!header?.startsWith("Bearer ")) return false;
|
||||
|
||||
const provided = encoder.encode(header.slice(7));
|
||||
const expected = encoder.encode(TOKEN);
|
||||
if (provided.byteLength !== expected.byteLength) return false;
|
||||
|
||||
return timingSafeEqual(provided, expected);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bound concurrent processing.
|
||||
*
|
||||
* sharp and ffmpeg will each take every core available. Without this, two large requests can make
|
||||
* the box unresponsive to everything including the health check.
|
||||
*/
|
||||
let running = 0;
|
||||
const queue: (() => void)[] = [];
|
||||
|
||||
async function withSlot<T>(work: () => Promise<T>): Promise<T> {
|
||||
if (running >= MAX_CONCURRENT_JOBS) {
|
||||
await new Promise<void>((resolve) => queue.push(resolve));
|
||||
}
|
||||
running++;
|
||||
try {
|
||||
return await work();
|
||||
} finally {
|
||||
running--;
|
||||
queue.shift()?.();
|
||||
}
|
||||
}
|
||||
|
||||
/** Serve stored bytes, honouring Range — browsers will not scrub a <video> served without it. */
|
||||
async function serveContent(id: string, request: Request): Promise<Response> {
|
||||
const asset = await readAsset(id);
|
||||
const file = Bun.file(contentPath(id));
|
||||
|
||||
const type =
|
||||
asset.kind === "image"
|
||||
? `image/${asset.format === "jpeg" ? "jpeg" : asset.format}`
|
||||
: file.type || "application/octet-stream";
|
||||
|
||||
const range = request.headers.get("range");
|
||||
const match = range?.match(/^bytes=(\d*)-(\d*)$/);
|
||||
|
||||
if (match) {
|
||||
const size = asset.bytes;
|
||||
const start = match[1] ? Number(match[1]) : 0;
|
||||
const end = match[2] ? Math.min(Number(match[2]), size - 1) : size - 1;
|
||||
|
||||
if (!Number.isFinite(start) || start > end || start >= size) {
|
||||
return new Response(null, { status: 416, headers: { "Content-Range": `bytes */${size}` } });
|
||||
}
|
||||
|
||||
return new Response(file.slice(start, end + 1), {
|
||||
status: 206,
|
||||
headers: {
|
||||
"Content-Type": type,
|
||||
"Content-Range": `bytes ${start}-${end}/${size}`,
|
||||
"Accept-Ranges": "bytes",
|
||||
"Content-Length": String(end - start + 1),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return new Response(file, {
|
||||
headers: {
|
||||
"Content-Type": type,
|
||||
"Accept-Ranges": "bytes",
|
||||
// Every id is unique and content never changes under one, so this is honest.
|
||||
"Cache-Control": "public, max-age=31536000, immutable",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function handleUpload(request: Request): Promise<Response> {
|
||||
const form = await request.formData().catch(() => {
|
||||
throw invalid("Expected a multipart/form-data body with a `file` field");
|
||||
});
|
||||
|
||||
const file = form.get("file");
|
||||
if (!(file instanceof File)) throw invalid("Expected a `file` field containing a file", "file");
|
||||
|
||||
if (file.size > MAX_UPLOAD_BYTES) {
|
||||
throw new ApiError(
|
||||
"payload_too_large",
|
||||
`That file is ${(file.size / 1_048_576).toFixed(1)} MB. The limit is ${(MAX_UPLOAD_BYTES / 1_048_576).toFixed(0)} MB.`,
|
||||
);
|
||||
}
|
||||
|
||||
const asset = await withSlot(async () => putAsset(await file.arrayBuffer()));
|
||||
return Response.json(asset, { status: 201 });
|
||||
}
|
||||
|
||||
async function route(request: Request): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
const path = url.pathname;
|
||||
|
||||
if (path === "/health") {
|
||||
return Response.json({ ok: true, service: "av-club", running, queued: queue.length });
|
||||
}
|
||||
|
||||
if (!authorised(request)) {
|
||||
return new ApiError("unauthorized", "Missing or invalid bearer token").toResponse();
|
||||
}
|
||||
|
||||
if (path === "/v1/assets" && request.method === "POST") return handleUpload(request);
|
||||
|
||||
const asset = path.match(/^\/v1\/assets\/([^/]+)(\/content|\/probe)?$/);
|
||||
if (asset) {
|
||||
const [, id, suffix] = asset;
|
||||
|
||||
if (suffix === "/content" && request.method === "GET") return serveContent(id!, request);
|
||||
|
||||
if (suffix === "/probe" && request.method === "GET") {
|
||||
const stored = await readAsset(id!);
|
||||
if (stored.kind === "image") {
|
||||
const metadata = await sharp(contentPath(id!)).metadata();
|
||||
return Response.json({ kind: "image", metadata });
|
||||
}
|
||||
const probe = await withSlot(() => ffprobe(contentPath(id!)));
|
||||
return Response.json({ kind: stored.kind, ...probe });
|
||||
}
|
||||
|
||||
if (!suffix && request.method === "GET") return Response.json(await readAsset(id!));
|
||||
|
||||
if (!suffix && request.method === "DELETE") {
|
||||
if (!(await readAsset(id!).then(() => true).catch(() => false))) throw notFound();
|
||||
await deleteAsset(id!);
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
}
|
||||
|
||||
if (path === "/v1/sharp" && request.method === "POST") {
|
||||
const body = (await request.json().catch(() => {
|
||||
throw invalid("Expected a JSON body");
|
||||
})) as SharpRequest;
|
||||
const result = await withSlot(() => runSharp(body));
|
||||
return Response.json(result, { status: 201 });
|
||||
}
|
||||
|
||||
if (path === "/v1/ffmpeg") {
|
||||
throw new ApiError("invalid_request", "POST /v1/ffmpeg is not implemented yet. See docs/API.md.");
|
||||
}
|
||||
|
||||
throw notFound(`No route for ${request.method} ${path}`);
|
||||
}
|
||||
|
||||
const server = Bun.serve({
|
||||
port: PORT,
|
||||
// Bun's default is 10 seconds, which silently kills a long encode mid-flight.
|
||||
idleTimeout: 255,
|
||||
maxRequestBodySize: MAX_UPLOAD_BYTES,
|
||||
fetch: (request) => route(request).catch(toErrorResponse),
|
||||
});
|
||||
|
||||
console.log(`av-club on ${server.url}`);
|
||||
console.log(` assets ${ASSETS_DIR}`);
|
||||
console.log(` max upload ${(MAX_UPLOAD_BYTES / 1_048_576).toFixed(0)} MB`);
|
||||
console.log(` concurrency ${MAX_CONCURRENT_JOBS}`);
|
||||
@@ -0,0 +1,134 @@
|
||||
import sharp from "sharp";
|
||||
import { MAX_INPUT_PIXELS } from "../config";
|
||||
import { invalid, unprocessable } from "../errors";
|
||||
import { contentPath, putAsset, readAsset, type Asset } from "../assets/store";
|
||||
import { INPUT_OPTIONS, OPERATIONS, OUTPUT_FORMATS } from "./vocabulary";
|
||||
import { validateObject } from "./validate";
|
||||
|
||||
export type SharpRequest = {
|
||||
input?: unknown;
|
||||
options?: unknown;
|
||||
pipeline?: unknown;
|
||||
output?: unknown;
|
||||
};
|
||||
|
||||
/**
|
||||
* Run a validated sharp pipeline and store the result as a new asset.
|
||||
*
|
||||
* The input asset is never modified — every operation produces a new asset. That is what makes it
|
||||
* safe to re-run a pipeline with different settings six months from now, which is the whole reason
|
||||
* originals are kept (docs/PIPELINE.md: "The original is never destroyed").
|
||||
*/
|
||||
export async function runSharp(body: SharpRequest): Promise<Asset> {
|
||||
if (typeof body.input !== "string") {
|
||||
throw invalid("input must be an asset id", "input");
|
||||
}
|
||||
|
||||
const source = await readAsset(body.input);
|
||||
if (source.kind !== "image") {
|
||||
throw invalid(`Asset ${source.id} is ${source.kind}, not an image. Use /v1/ffmpeg for video and audio.`, "input");
|
||||
}
|
||||
|
||||
const inputOptions = validateObject(INPUT_OPTIONS, body.options, "options");
|
||||
|
||||
// limitInputPixels is set HERE and cannot be supplied by the caller: it is the decompression-bomb
|
||||
// guard, and a caller who could raise it could turn a 1 KB PNG into a 40 GB allocation.
|
||||
let image = sharp(contentPath(source.id), {
|
||||
...inputOptions,
|
||||
limitInputPixels: MAX_INPUT_PIXELS,
|
||||
});
|
||||
|
||||
// ── The pipeline ─────────────────────────────────────────────────────────────────────────────
|
||||
const steps = body.pipeline ?? [];
|
||||
if (!Array.isArray(steps)) throw invalid("pipeline must be an array of operations", "pipeline");
|
||||
|
||||
for (const [i, step] of steps.entries()) {
|
||||
const at = `pipeline[${i}]`;
|
||||
if (!step || typeof step !== "object" || Array.isArray(step)) {
|
||||
throw invalid(`${at} must be an object like { "resize": { "width": 800 } }`, at);
|
||||
}
|
||||
|
||||
const entries = Object.entries(step as Record<string, unknown>);
|
||||
if (entries.length !== 1) {
|
||||
throw invalid(`${at} must name exactly one operation, got ${entries.length}`, at);
|
||||
}
|
||||
|
||||
const [name, rawArgs] = entries[0]!;
|
||||
const spec = OPERATIONS[name];
|
||||
if (!spec) {
|
||||
throw invalid(
|
||||
`Unknown operation ${JSON.stringify(name)}. See docs/SHARP.md for the full list.`,
|
||||
`${at}.${name}`,
|
||||
);
|
||||
}
|
||||
|
||||
const args = validateObject(spec.params, rawArgs, `${at}.${name}`);
|
||||
const method = (image as unknown as Record<string, (...a: unknown[]) => typeof image>)[name];
|
||||
if (typeof method !== "function") {
|
||||
// The vocabulary named something sharp does not have — our bug, not the caller's.
|
||||
throw new Error(`vocabulary lists ${name}, but sharp has no such method`);
|
||||
}
|
||||
|
||||
try {
|
||||
// Operations sharp declares positionally — median(size), blur(sigma) — are called that way.
|
||||
// Everything else takes an options object.
|
||||
if (spec.positional && spec.positional in args) {
|
||||
const positional = args[spec.positional];
|
||||
const rest = { ...args };
|
||||
delete rest[spec.positional];
|
||||
image = Object.keys(rest).length
|
||||
? method.call(image, positional, rest)
|
||||
: method.call(image, positional);
|
||||
} else if (Object.keys(args).length === 0) {
|
||||
image = method.call(image);
|
||||
} else if (name === "composite") {
|
||||
// composite() takes an ARRAY, and its `input` is an asset id that only the store may turn
|
||||
// into a path. This is the one operation that reads a second file, and it is the reason
|
||||
// `assetId` exists as a parameter kind.
|
||||
const overlay = await readAsset(args.input as string);
|
||||
if (overlay.kind !== "image") {
|
||||
throw invalid(`composite input ${overlay.id} is ${overlay.kind}, not an image`, `${at}.composite.input`);
|
||||
}
|
||||
const { input: _dropped, ...placement } = args;
|
||||
image = image.composite([{ input: contentPath(overlay.id), ...placement } as never]);
|
||||
} else {
|
||||
image = method.call(image, args);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error && !("type" in error)) {
|
||||
throw invalid(`${name} failed: ${error.message}`, `${at}.${name}`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Output ───────────────────────────────────────────────────────────────────────────────────
|
||||
const output = (body.output ?? {}) as Record<string, unknown>;
|
||||
if (typeof output !== "object" || Array.isArray(output)) {
|
||||
throw invalid("output must be an object", "output");
|
||||
}
|
||||
|
||||
const format = output.format ?? "webp";
|
||||
if (typeof format !== "string" || !OUTPUT_FORMATS[format]) {
|
||||
throw invalid(
|
||||
`output.format must be one of: ${Object.keys(OUTPUT_FORMATS).join(", ")}`,
|
||||
"output.format",
|
||||
);
|
||||
}
|
||||
|
||||
const encoderOptions = validateObject(OUTPUT_FORMATS[format]!.params, output.options, "output.options");
|
||||
const encode = (image as unknown as Record<string, (o: unknown) => typeof image>)[format]!;
|
||||
|
||||
let buffer: Buffer;
|
||||
try {
|
||||
buffer = await encode.call(image, encoderOptions).toBuffer();
|
||||
} catch (error) {
|
||||
// A failure here is usually the source file, not the request — a truncated JPEG, a PNG whose
|
||||
// declared dimensions disagree with its data.
|
||||
throw unprocessable(
|
||||
`Could not process that image: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
|
||||
return putAsset(buffer);
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { invalid } from "../errors";
|
||||
import type { Param } from "./vocabulary";
|
||||
|
||||
/**
|
||||
* Validate caller-supplied values against the vocabulary table.
|
||||
*
|
||||
* Default-deny throughout: an unknown operation, an unknown parameter, or a value of the wrong shape
|
||||
* is refused. Nothing is coerced and nothing is silently dropped — a typo'd parameter that was
|
||||
* quietly ignored would look exactly like an option that does not work.
|
||||
*
|
||||
* Error messages name the exact path (`pipeline[1].resize.width`) and say what was expected, because
|
||||
* the thing on the other end is usually relaying them to a person.
|
||||
*/
|
||||
|
||||
const ID_PATTERN = /^as_[0-9a-f]{32}$/;
|
||||
|
||||
/** sharp accepts a CSS colour string or {r,g,b,alpha}. Both are data — neither can reference a file. */
|
||||
function validateColor(value: unknown, path: string): unknown {
|
||||
if (typeof value === "string") {
|
||||
if (value.length > 64) throw invalid("Colour string is too long", path);
|
||||
return value;
|
||||
}
|
||||
if (value && typeof value === "object" && !Array.isArray(value)) {
|
||||
const obj = value as Record<string, unknown>;
|
||||
for (const key of Object.keys(obj)) {
|
||||
if (!["r", "g", "b", "alpha"].includes(key)) {
|
||||
throw invalid(`Unknown colour component ${JSON.stringify(key)}. Expected r, g, b or alpha.`, path);
|
||||
}
|
||||
const component = obj[key];
|
||||
if (typeof component !== "number" || !Number.isFinite(component)) {
|
||||
throw invalid(`Colour component ${key} must be a number`, `${path}.${key}`);
|
||||
}
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
throw invalid("Expected a colour: a CSS string like '#ffffff', or {r, g, b, alpha}", path);
|
||||
}
|
||||
|
||||
export function validateValue(spec: Param, value: unknown, path: string): unknown {
|
||||
switch (spec.kind) {
|
||||
case "int": {
|
||||
if (typeof value !== "number" || !Number.isInteger(value)) {
|
||||
throw invalid(`${path} must be an integer`, path);
|
||||
}
|
||||
if (value < spec.min || value > spec.max) {
|
||||
throw invalid(`${path} must be between ${spec.min} and ${spec.max}, got ${value}`, path);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
case "number": {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||
throw invalid(`${path} must be a number`, path);
|
||||
}
|
||||
if (value < spec.min || value > spec.max) {
|
||||
throw invalid(`${path} must be between ${spec.min} and ${spec.max}, got ${value}`, path);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
case "bool": {
|
||||
if (typeof value !== "boolean") throw invalid(`${path} must be true or false`, path);
|
||||
return value;
|
||||
}
|
||||
case "enum": {
|
||||
if (typeof value !== "string" || !spec.values.includes(value)) {
|
||||
throw invalid(`${path} must be one of: ${spec.values.join(", ")}`, path);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
case "color":
|
||||
return validateColor(value, path);
|
||||
case "numberArray": {
|
||||
if (!Array.isArray(value)) throw invalid(`${path} must be an array of numbers`, path);
|
||||
if (value.length < spec.minLength || value.length > spec.maxLength) {
|
||||
throw invalid(
|
||||
`${path} must have between ${spec.minLength} and ${spec.maxLength} entries, got ${value.length}`,
|
||||
path,
|
||||
);
|
||||
}
|
||||
value.forEach((entry, i) => {
|
||||
if (typeof entry !== "number" || !Number.isFinite(entry)) {
|
||||
throw invalid(`${path}[${i}] must be a number`, `${path}[${i}]`);
|
||||
}
|
||||
if (entry < spec.min || entry > spec.max) {
|
||||
throw invalid(`${path}[${i}] must be between ${spec.min} and ${spec.max}`, `${path}[${i}]`);
|
||||
}
|
||||
});
|
||||
return value;
|
||||
}
|
||||
case "assetId": {
|
||||
// The ONLY way a caller may reference another file. Checked here against the same pattern the
|
||||
// store issues, then resolved to a path by the store and nowhere else. See docs/SECURITY.md.
|
||||
if (typeof value !== "string" || !ID_PATTERN.test(value)) {
|
||||
throw invalid(`${path} must be an asset id (as_ followed by 32 hex characters)`, path);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
case "string": {
|
||||
if (typeof value !== "string") throw invalid(`${path} must be a string`, path);
|
||||
if (value.length > spec.maxLength) {
|
||||
throw invalid(`${path} must be at most ${spec.maxLength} characters`, path);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Validate an options object against a table of parameter specs. Unknown keys are refused. */
|
||||
export function validateObject(
|
||||
params: Record<string, Param>,
|
||||
value: unknown,
|
||||
path: string,
|
||||
): Record<string, unknown> {
|
||||
if (value === undefined || value === null) return {};
|
||||
if (typeof value !== "object" || Array.isArray(value)) {
|
||||
throw invalid(`${path} must be an object`, path);
|
||||
}
|
||||
|
||||
const input = value as Record<string, unknown>;
|
||||
const out: Record<string, unknown> = {};
|
||||
|
||||
for (const [key, raw] of Object.entries(input)) {
|
||||
const spec = params[key];
|
||||
if (!spec) {
|
||||
const known = Object.keys(params);
|
||||
throw invalid(
|
||||
known.length
|
||||
? `Unknown option ${JSON.stringify(key)}. Accepted here: ${known.join(", ")}`
|
||||
: `${path} takes no options, got ${JSON.stringify(key)}`,
|
||||
`${path}.${key}`,
|
||||
);
|
||||
}
|
||||
if (raw === undefined) continue;
|
||||
out[key] = validateValue(spec, raw, `${path}.${key}`);
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
import { MAX_OUTPUT_DIMENSION } from "../config";
|
||||
|
||||
/**
|
||||
* The sharp surface av-club exposes, as data.
|
||||
*
|
||||
* Every operation is named what SHARP names it and takes SHARP's arguments, so sharp's own
|
||||
* documentation is the reference. This table is the single source of truth for three things that
|
||||
* would otherwise drift apart:
|
||||
*
|
||||
* 1. what the validator accepts
|
||||
* 2. what docs/SHARP.md says is accepted
|
||||
* 3. what is actually called
|
||||
*
|
||||
* Adding an operation is one entry here.
|
||||
*
|
||||
* ── Why a table rather than "pass the options through" ──
|
||||
*
|
||||
* sharp is memory-safe in the ways ffmpeg is not — it takes buffers and returns buffers, and has no
|
||||
* protocol handlers, no plugin loading and no filesystem syntax. Its danger is RESOURCE
|
||||
* EXHAUSTION rather than code execution: `resize(50000, 50000)` is an allocation request, `blur(10000)`
|
||||
* is a very long time. So every numeric parameter is bounded, and the bound is written next to the
|
||||
* parameter rather than checked somewhere else.
|
||||
*/
|
||||
|
||||
export type Param =
|
||||
| { kind: "int"; min: number; max: number; note?: string }
|
||||
| { kind: "number"; min: number; max: number; note?: string }
|
||||
| { kind: "bool"; note?: string }
|
||||
| { kind: "enum"; values: readonly string[]; note?: string }
|
||||
| { kind: "color"; note?: string }
|
||||
| { kind: "numberArray"; minLength: number; maxLength: number; min: number; max: number; note?: string }
|
||||
| { kind: "assetId"; note?: string }
|
||||
| { kind: "string"; maxLength: number; note?: string };
|
||||
|
||||
export type OpSpec = {
|
||||
/** One line, used in docs/SHARP.md. */
|
||||
describe: string;
|
||||
params: Record<string, Param>;
|
||||
/** Operations that take a bare value rather than an options object, e.g. `median(size)`. */
|
||||
positional?: string;
|
||||
};
|
||||
|
||||
const DIM = MAX_OUTPUT_DIMENSION;
|
||||
|
||||
/** sharp's constructor options. */
|
||||
export const INPUT_OPTIONS: Record<string, Param> = {
|
||||
failOn: { kind: "enum", values: ["none", "truncated", "error", "warning"], note: "How strict to be about a damaged file. sharp's default is 'warning'." },
|
||||
density: { kind: "number", min: 1, max: 2400, note: "DPI for vector input." },
|
||||
pages: { kind: "int", min: -1, max: 1000, note: "Pages to read from a multi-page input. -1 for all." },
|
||||
page: { kind: "int", min: 0, max: 100_000, note: "First page to read." },
|
||||
animated: { kind: "bool", note: "Read all frames of an animated GIF/WebP. Equivalent to pages: -1." },
|
||||
ignoreIcc: { kind: "bool", note: "Ignore any embedded ICC profile." },
|
||||
autoOrient: { kind: "bool", note: "Apply EXIF orientation on load. See the note about rotate() in docs/SHARP.md." },
|
||||
sequentialRead: { kind: "bool" },
|
||||
level: { kind: "int", min: 0, max: 256, note: "Level to extract from a multi-level input." },
|
||||
subifd: { kind: "int", min: -1, max: 100_000, note: "Subldeaf IFD to extract from an OME-TIFF." },
|
||||
};
|
||||
|
||||
/**
|
||||
* The pipeline operations.
|
||||
*
|
||||
* `limitInputPixels` and `withMetadata` are DELIBERATELY ABSENT and must stay that way:
|
||||
* - limitInputPixels is the decompression-bomb guard and is set by the server, not the caller.
|
||||
* - withMetadata re-attaches EXIF, which is how a client's GPS coordinates get published.
|
||||
* See docs/SECURITY.md.
|
||||
*/
|
||||
export const OPERATIONS: Record<string, OpSpec> = {
|
||||
// ── Resizing ───────────────────────────────────────────────────────────────────────────────
|
||||
resize: {
|
||||
describe: "Resize to given dimensions.",
|
||||
params: {
|
||||
width: { kind: "int", min: 1, max: DIM },
|
||||
height: { kind: "int", min: 1, max: DIM },
|
||||
fit: { kind: "enum", values: ["cover", "contain", "fill", "inside", "outside"], note: "How the image fits the box. Default 'cover'." },
|
||||
position: { kind: "enum", values: ["centre", "center", "top", "right top", "right", "right bottom", "bottom", "left bottom", "left", "left top", "north", "northeast", "east", "southeast", "south", "southwest", "west", "northwest", "entropy", "attention"], note: "Which part survives when fit is 'cover'. 'attention' picks the busiest region." },
|
||||
background: { kind: "color", note: "Fill colour when fit is 'contain'." },
|
||||
kernel: { kind: "enum", values: ["nearest", "linear", "cubic", "mitchell", "lanczos2", "lanczos3"] },
|
||||
withoutEnlargement: { kind: "bool", note: "Never scale UP. Almost always what you want — see docs/SHARP.md." },
|
||||
withoutReduction: { kind: "bool", note: "Never scale DOWN." },
|
||||
fastShrinkOnLoad: { kind: "bool" },
|
||||
},
|
||||
},
|
||||
extend: {
|
||||
describe: "Add pixels to the edges.",
|
||||
params: {
|
||||
top: { kind: "int", min: 0, max: DIM },
|
||||
bottom: { kind: "int", min: 0, max: DIM },
|
||||
left: { kind: "int", min: 0, max: DIM },
|
||||
right: { kind: "int", min: 0, max: DIM },
|
||||
extendWith: { kind: "enum", values: ["background", "copy", "repeat", "mirror"] },
|
||||
background: { kind: "color" },
|
||||
},
|
||||
},
|
||||
extract: {
|
||||
describe: "Crop to a region.",
|
||||
params: {
|
||||
left: { kind: "int", min: 0, max: DIM },
|
||||
top: { kind: "int", min: 0, max: DIM },
|
||||
width: { kind: "int", min: 1, max: DIM },
|
||||
height: { kind: "int", min: 1, max: DIM },
|
||||
},
|
||||
},
|
||||
trim: {
|
||||
describe: "Trim uniform borders.",
|
||||
params: {
|
||||
background: { kind: "color" },
|
||||
threshold: { kind: "number", min: 0, max: 255 },
|
||||
lineArt: { kind: "bool" },
|
||||
},
|
||||
},
|
||||
|
||||
// ── Orientation ────────────────────────────────────────────────────────────────────────────
|
||||
rotate: {
|
||||
describe: "Rotate. With no angle, applies the EXIF orientation — see docs/SHARP.md.",
|
||||
params: {
|
||||
angle: { kind: "number", min: -3600, max: 3600, note: "Multiples of 90 are lossless; anything else needs a background." },
|
||||
background: { kind: "color" },
|
||||
},
|
||||
},
|
||||
flip: { describe: "Mirror vertically.", params: { flip: { kind: "bool" } }, positional: "flip" },
|
||||
flop: { describe: "Mirror horizontally.", params: { flop: { kind: "bool" } }, positional: "flop" },
|
||||
|
||||
// ── Pixel operations ───────────────────────────────────────────────────────────────────────
|
||||
sharpen: {
|
||||
describe: "Sharpen.",
|
||||
params: {
|
||||
sigma: { kind: "number", min: 0.000001, max: 10 },
|
||||
m1: { kind: "number", min: 0, max: 1_000_000 },
|
||||
m2: { kind: "number", min: 0, max: 1_000_000 },
|
||||
x1: { kind: "number", min: 0, max: 1_000_000 },
|
||||
y2: { kind: "number", min: 0, max: 1_000_000 },
|
||||
y3: { kind: "number", min: 0, max: 1_000_000 },
|
||||
},
|
||||
},
|
||||
blur: {
|
||||
describe: "Gaussian blur.",
|
||||
params: { sigma: { kind: "number", min: 0.3, max: 1000 } },
|
||||
positional: "sigma",
|
||||
},
|
||||
median: {
|
||||
describe: "Median filter — good for noise.",
|
||||
params: { size: { kind: "int", min: 1, max: 1000 } },
|
||||
positional: "size",
|
||||
},
|
||||
dilate: { describe: "Morphological dilate.", params: { width: { kind: "int", min: 1, max: 1000 } }, positional: "width" },
|
||||
erode: { describe: "Morphological erode.", params: { width: { kind: "int", min: 1, max: 1000 } }, positional: "width" },
|
||||
flatten: { describe: "Composite transparency onto a background.", params: { background: { kind: "color" } } },
|
||||
unflatten: { describe: "Make pure white pixels transparent.", params: {} },
|
||||
gamma: {
|
||||
describe: "Apply gamma correction.",
|
||||
params: { gamma: { kind: "number", min: 1, max: 3 }, gammaOut: { kind: "number", min: 1, max: 3 } },
|
||||
},
|
||||
negate: { describe: "Invert.", params: { alpha: { kind: "bool", note: "Also invert the alpha channel." } } },
|
||||
normalise: {
|
||||
describe: "Stretch the histogram to full range.",
|
||||
params: { lower: { kind: "number", min: 0, max: 99 }, upper: { kind: "number", min: 1, max: 100 } },
|
||||
},
|
||||
clahe: {
|
||||
describe: "Contrast-limited adaptive histogram equalisation.",
|
||||
params: {
|
||||
width: { kind: "int", min: 1, max: DIM },
|
||||
height: { kind: "int", min: 1, max: DIM },
|
||||
maxSlope: { kind: "int", min: 0, max: 100 },
|
||||
},
|
||||
},
|
||||
convolve: {
|
||||
describe: "Apply a convolution kernel.",
|
||||
params: {
|
||||
width: { kind: "int", min: 1, max: 100 },
|
||||
height: { kind: "int", min: 1, max: 100 },
|
||||
kernel: { kind: "numberArray", minLength: 1, maxLength: 10_000, min: -1000, max: 1000 },
|
||||
scale: { kind: "number", min: -10_000, max: 10_000 },
|
||||
offset: { kind: "number", min: -10_000, max: 10_000 },
|
||||
},
|
||||
},
|
||||
threshold: {
|
||||
describe: "Reduce to black and white at a cutoff.",
|
||||
params: { threshold: { kind: "int", min: 0, max: 255 }, greyscale: { kind: "bool" } },
|
||||
},
|
||||
linear: {
|
||||
describe: "Multiply and add: out = in * a + b.",
|
||||
params: {
|
||||
a: { kind: "numberArray", minLength: 1, maxLength: 4, min: -1000, max: 1000 },
|
||||
b: { kind: "numberArray", minLength: 1, maxLength: 4, min: -1000, max: 1000 },
|
||||
},
|
||||
},
|
||||
recomb: {
|
||||
describe: "Recombine channels with a 3x3 matrix.",
|
||||
params: { matrix: { kind: "numberArray", minLength: 9, maxLength: 9, min: -100, max: 100 } },
|
||||
positional: "matrix",
|
||||
},
|
||||
modulate: {
|
||||
describe: "Adjust brightness, saturation, hue and lightness.",
|
||||
params: {
|
||||
brightness: { kind: "number", min: 0, max: 100 },
|
||||
saturation: { kind: "number", min: 0, max: 100 },
|
||||
hue: { kind: "number", min: -3600, max: 3600, note: "Degrees." },
|
||||
lightness: { kind: "number", min: -100, max: 100 },
|
||||
},
|
||||
},
|
||||
|
||||
// ── Colour ─────────────────────────────────────────────────────────────────────────────────
|
||||
tint: { describe: "Tint with a colour.", params: { color: { kind: "color" } }, positional: "color" },
|
||||
greyscale: { describe: "Convert to greyscale.", params: { greyscale: { kind: "bool" } }, positional: "greyscale" },
|
||||
toColourspace: {
|
||||
describe: "Convert to a colourspace.",
|
||||
params: { colourspace: { kind: "enum", values: ["srgb", "rgb", "cmyk", "lab", "b-w", "hsv", "scrgb", "lch", "grey16", "rgb16"] } },
|
||||
positional: "colourspace",
|
||||
},
|
||||
pipelineColourspace: {
|
||||
describe: "Set the colourspace operations are performed in.",
|
||||
params: { colourspace: { kind: "enum", values: ["srgb", "rgb", "cmyk", "lab", "b-w", "hsv", "scrgb", "lch", "grey16", "rgb16"] } },
|
||||
positional: "colourspace",
|
||||
},
|
||||
|
||||
// ── Channels ───────────────────────────────────────────────────────────────────────────────
|
||||
removeAlpha: { describe: "Drop the alpha channel.", params: {} },
|
||||
ensureAlpha: { describe: "Add an alpha channel if absent.", params: { alpha: { kind: "number", min: 0, max: 1 } }, positional: "alpha" },
|
||||
extractChannel: {
|
||||
describe: "Keep one channel.",
|
||||
params: { channel: { kind: "enum", values: ["red", "green", "blue", "alpha", "0", "1", "2", "3"] } },
|
||||
positional: "channel",
|
||||
},
|
||||
bandbool: {
|
||||
describe: "Boolean-combine all channels into one.",
|
||||
params: { operator: { kind: "enum", values: ["and", "or", "eor"] } },
|
||||
positional: "operator",
|
||||
},
|
||||
|
||||
// ── Composite ──────────────────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// `input` is an ASSET ID, not a path. That is what makes compositing safe to expose at all:
|
||||
// sharp's own composite() takes a filename, which would hand the caller arbitrary file reads.
|
||||
composite: {
|
||||
describe: "Overlay another asset. `input` is an asset id, never a path.",
|
||||
params: {
|
||||
input: { kind: "assetId", note: "The asset to overlay. Must already be uploaded." },
|
||||
blend: { kind: "enum", values: ["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: { kind: "enum", values: ["centre", "center", "north", "northeast", "east", "southeast", "south", "southwest", "west", "northwest"] },
|
||||
top: { kind: "int", min: 0, max: DIM },
|
||||
left: { kind: "int", min: 0, max: DIM },
|
||||
tile: { kind: "bool" },
|
||||
premultiplied: { kind: "bool" },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/** Output encoders, named as sharp names them, with sharp's own option names. */
|
||||
export const OUTPUT_FORMATS: Record<string, { describe: string; params: Record<string, Param> }> = {
|
||||
jpeg: {
|
||||
describe: "JPEG.",
|
||||
params: {
|
||||
quality: { kind: "int", min: 1, max: 100 },
|
||||
progressive: { kind: "bool" },
|
||||
chromaSubsampling: { kind: "enum", values: ["4:4:4", "4:2:2", "4:2:0"] },
|
||||
optimiseCoding: { kind: "bool" },
|
||||
mozjpeg: { kind: "bool", note: "Turns on a bundle of mozjpeg settings at once." },
|
||||
trellisQuantisation: { kind: "bool" },
|
||||
overshootDeringing: { kind: "bool" },
|
||||
optimiseScans: { kind: "bool" },
|
||||
},
|
||||
},
|
||||
png: {
|
||||
describe: "PNG.",
|
||||
params: {
|
||||
progressive: { kind: "bool" },
|
||||
compressionLevel: { kind: "int", min: 0, max: 9 },
|
||||
adaptiveFiltering: { kind: "bool" },
|
||||
palette: { kind: "bool", note: "Quantise to a palette — much smaller for flat graphics." },
|
||||
quality: { kind: "int", min: 0, max: 100 },
|
||||
effort: { kind: "int", min: 1, max: 10 },
|
||||
colours: { kind: "int", min: 2, max: 256 },
|
||||
dither: { kind: "number", min: 0, max: 1 },
|
||||
},
|
||||
},
|
||||
webp: {
|
||||
describe: "WebP.",
|
||||
params: {
|
||||
quality: { kind: "int", min: 1, max: 100 },
|
||||
alphaQuality: { kind: "int", min: 0, max: 100 },
|
||||
lossless: { kind: "bool" },
|
||||
nearLossless: { kind: "bool" },
|
||||
smartSubsample: { kind: "bool" },
|
||||
preset: { kind: "enum", values: ["default", "photo", "picture", "drawing", "icon", "text"] },
|
||||
effort: { kind: "int", min: 0, max: 6 },
|
||||
loop: { kind: "int", min: 0, max: 100_000 },
|
||||
minSize: { kind: "bool" },
|
||||
mixed: { kind: "bool" },
|
||||
},
|
||||
},
|
||||
avif: {
|
||||
describe: "AVIF. Smaller than WebP at the same quality, slower to encode.",
|
||||
params: {
|
||||
quality: { kind: "int", min: 1, max: 100 },
|
||||
lossless: { kind: "bool" },
|
||||
effort: { kind: "int", min: 0, max: 9, note: "Higher is smaller and much slower." },
|
||||
chromaSubsampling: { kind: "enum", values: ["4:4:4", "4:2:0"] },
|
||||
bitdepth: { kind: "enum", values: ["8", "10", "12"] },
|
||||
},
|
||||
},
|
||||
tiff: {
|
||||
describe: "TIFF.",
|
||||
params: {
|
||||
quality: { kind: "int", min: 1, max: 100 },
|
||||
compression: { kind: "enum", values: ["none", "jpeg", "deflate", "packbits", "ccittfax4", "lzw", "webp", "zstd", "jp2k"] },
|
||||
predictor: { kind: "enum", values: ["none", "horizontal", "float"] },
|
||||
pyramid: { kind: "bool" },
|
||||
tile: { kind: "bool" },
|
||||
tileWidth: { kind: "int", min: 1, max: DIM },
|
||||
tileHeight: { kind: "int", min: 1, max: DIM },
|
||||
xres: { kind: "number", min: 0.01, max: 100_000 },
|
||||
yres: { kind: "number", min: 0.01, max: 100_000 },
|
||||
resolutionUnit: { kind: "enum", values: ["inch", "cm"] },
|
||||
bitdepth: { kind: "enum", values: ["1", "2", "4", "8"] },
|
||||
},
|
||||
},
|
||||
gif: {
|
||||
describe: "GIF.",
|
||||
params: {
|
||||
reuse: { kind: "bool" },
|
||||
progressive: { kind: "bool" },
|
||||
colours: { kind: "int", min: 2, max: 256 },
|
||||
effort: { kind: "int", min: 1, max: 10 },
|
||||
dither: { kind: "number", min: 0, max: 1 },
|
||||
interFrameMaxError: { kind: "number", min: 0, max: 32 },
|
||||
interPaletteMaxError: { kind: "number", min: 0, max: 256 },
|
||||
loop: { kind: "int", min: 0, max: 100_000 },
|
||||
},
|
||||
},
|
||||
jp2: {
|
||||
describe: "JPEG 2000.",
|
||||
params: {
|
||||
quality: { kind: "int", min: 1, max: 100 },
|
||||
lossless: { kind: "bool" },
|
||||
tileWidth: { kind: "int", min: 1, max: DIM },
|
||||
tileHeight: { kind: "int", min: 1, max: DIM },
|
||||
chromaSubsampling: { kind: "enum", values: ["4:4:4", "4:2:2", "4:2:0"] },
|
||||
},
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user