truss is an image transformation tool with a shared Rust core for a CLI, an HTTP server, and a browser/WASM build.
Use the CLI for local files and shell pipelines, run the server behind a CDN or reverse proxy, process files in the browser with @nao1215/truss-wasm, or sign public URLs from Node.js with @nao1215/truss-url-signer.
Try the WASM demo in your browser - no install, no upload, runs 100 % client-side.
| If you want to... | Start with | Read next |
|---|---|---|
| Convert local files from the shell | brew install nao1215/tap/truss or cargo install truss-image, then truss photo.png -o photo.jpg |
CLI, Commands |
| Run an HTTP image server | TRUSS_BEARER_TOKEN=changeme truss serve --bind 0.0.0.0:8080 --storage-root ./images |
HTTP Server, API Reference, Deployment Guide |
| Process files in a browser app | npm install @nao1215/truss-wasm |
WASM, WASM Integration |
| Generate signed public URLs from Node.js | npm install @nao1215/truss-url-signer |
TypeScript URL Signing, Signed URL Specification |
- One Rust core across the CLI, the HTTP server, and the browser/WASM build.
- Signed URLs, SSRF protections, and SVG sanitization are built in.
- Supports JPEG, PNG, WebP, AVIF, BMP, TIFF, and SVG, plus GIF as a decode-only input.
- Runs on Linux, macOS, and Windows.
- CLI behavior is covered by atago, and the HTTP API by runn.
brew install nao1215/tap/trussyay -S truss-bin # or: paru -S truss-bintruss-bin is community-maintained and installs the release binary.
cargo install truss-imageNeed S3, GCS, or Azure storage backend support at install time? See the Deployment Guide.
Prebuilt binaries are available on the GitHub Releases page. See Deployment Guide for all targets and Docker images.
Every release attaches release-manifest.json next to the archives. For each published target it records the Rust target triple, OS, architecture, archive name, download URL, archive SHA-256 and byte size, the SHA-256 and byte size of the truss executable inside the archive, and the Cargo features that binary was built with. The document carries a schemaVersion; fields may be added within a version, so ignore keys you do not recognise.
Check the SHA-256 of a downloaded archive against the manifest before extracting it, and check the extracted executable against binary.sha256. checksums.txt lists the same archive digests and is generated from the manifest.
The features array tells you which optional features a given binary was built with, such as avif or svg.
Pin a tag rather than following the latest release. Archive names, URLs and digests are all specific to one tag.
Install only the package you need:
# Browser/WASM package
npm install @nao1215/truss-wasm
# Node.js URL signer
npm install @nao1215/truss-url-signerPackage source and package-specific READMEs live in packages/truss-wasm and packages/truss-url-signer.
docker pull ghcr.io/nao1215/truss:latestThe convert subcommand can be omitted: truss photo.png -o photo.jpg is equivalent to truss convert photo.png -o photo.jpg. Run truss convert --help to see the full set of options.
# Convert format
truss photo.png -o photo.jpg
# Resize + convert
truss photo.png -o thumb.webp --width 800 --format webp --quality 75
# Optimize in place with the shared pipeline
truss optimize photo.jpg -o photo-optimized.jpg --mode auto
# Convert from a remote URL
truss --url https://example.com/img.png -o out.avif --format avif
# Sanitize SVG (remove scripts and external references)
truss diagram.svg -o safe.svg
# Inspect metadata
truss inspect photo.jpginspect reports both the dimensions stored in the file (width, height) and the ones
convert produces (orientedWidth, orientedHeight). They differ when the file carries an
EXIF orientation that transposes the axes, which is the normal case for a phone photo taken
in portrait:
$ truss inspect portrait.jpg
{
"format": "jpeg",
"mime": "image/jpeg",
"width": 4032,
"height": 3024,
"orientation": 6,
"orientedWidth": 3024,
"orientedHeight": 4032,
"hasAlpha": false,
"isAnimated": false
}truss supports JPEG, PNG, WebP, AVIF, BMP, TIFF, and SVG. The output format is inferred from the file extension, or you can specify it explicitly with --format.
GIF is read but never written. --format gif is rejected, and a GIF input with no other
format hint converts to PNG rather than back to GIF.
| Format | File size (640 x 427) | Notes |
|---|---|---|
| JPEG (original) | 80 KB | Lossy, widely supported |
WebP (--quality 80) |
38 KB | ~52 % smaller than JPEG |
AVIF (--quality 50) |
17 KB | ~79 % smaller than JPEG |
| PNG | 480 KB | Lossless |
# JPEG -> WebP (smaller file, same visual quality)
truss photo.jpg -o photo.webp --quality 80
# JPEG -> AVIF (best compression)
truss photo.jpg -o photo.avif --quality 50
# Explicit format override (ignore extension)
truss photo.jpg -o output.bin --format pngUse --quality <1-100> to control lossy encoding. Lower values produce smaller files at the cost of visual quality.
Use --optimize auto|lossless|lossy on truss convert, or the dedicated truss optimize subcommand, to reduce output size with format-aware encoding choices. Add --target-quality ssim:0.98 or --target-quality psnr:42 when you want lossy optimization to aim for a specific perceptual threshold.
| Quality 90 (95 KB) | Original (80 KB) | Quality 30 (27 KB) |
|---|---|---|
![]() |
![]() |
![]() |
GIF is a decode-only format: truss reads it and writes one of the formats it can encode.
# Convert a GIF to PNG, keeping the palette colors and any transparent index
truss photo.gif -o photo.png
# Any transform works the same as for other raster inputs
truss photo.gif -o thumb.webp --width 320 --format webp
# Check whether an upload is animated before converting it
truss inspect upload.gifAn animation is refused rather than reduced to its first frame, whether it arrived as a GIF, an animated WebP, an APNG, or an animated AVIF:
$ truss convert animated.gif -o out.png
error: animated gif is not supported (3 frames); truss transforms single-frame images only
$ echo $?
3Silently returning frame one with exit code 0 would give a caller no way to notice the
animation was discarded. truss inspect still reads all four and reports
"isAnimated": true, so a pipeline can branch on that before converting.
Specify --width and/or --height to resize. When both are given, --fit controls how the image fits the target box:
| Mode | Output size | Behavior |
|---|---|---|
contain (default) |
exactly the box | Scale to fit inside the box, preserving aspect ratio, then pad the remainder with --background. |
cover |
exactly the box | Scale to fill the box, preserving aspect ratio, then crop the excess. Use --position to choose the crop anchor. |
fill |
exactly the box | Stretch each axis to the box, ignoring aspect ratio. |
inside |
at most the box | Scale to fit inside the box, preserving aspect ratio, and add no padding. Usually smaller than the box on one axis. |
contain and inside scale the image identically. The difference is what happens next:
a 640 x 427 photo bounded by 300 x 300 becomes 300 x 200 either way, and contain then pads
that out to 300 x 300 while inside returns it as is.
| Original (640 x 427) | contain 300 x 300 | cover 300 x 300 | fill 300 x 300 | inside 300 x 300 |
|---|---|---|---|---|
![]() |
![]() |
![]() |
![]() |
![]() |
# contain -- fit inside the box, pad with gray background
truss photo.jpg -o out.jpg --width 300 --height 300 --fit contain --background CCCCCC
# cover -- fill the box, crop the excess
truss photo.jpg -o out.jpg --width 300 --height 300 --fit cover
# fill -- stretch to exact dimensions
truss photo.jpg -o out.jpg --width 300 --height 300 --fit fill
# inside -- bound the image by the box without padding it
truss photo.jpg -o out.jpg --width 300 --height 300 --fit inside
# Width only -- height is calculated to preserve aspect ratio
truss photo.jpg -o out.jpg --width 800Whether a resize may scale an image up is a separate question from how it fits the box, so
it is a separate flag rather than part of a fit mode. --without-enlargement works with any
--fit, and with a single-axis resize:
# The everyday "max 800x600, leave small images alone" resize
truss photo.jpg -o out.jpg --width 800 --height 600 --fit inside --without-enlargement
# Contain still pads out to the full box; only the content stops growing
truss photo.jpg -o out.jpg --width 800 --height 600 --fit contain --without-enlargement
# Works on a single axis too
truss photo.jpg -o out.jpg --width 800 --without-enlargementWithout it, a source smaller than the requested size is scaled up to reach it.
When using --fit cover, --position controls which part of the image is kept:
--position top-left |
--position center (default) |
--position bottom-right |
|---|---|---|
![]() |
![]() |
![]() |
Available positions: center, top, right, bottom, left, top-left, top-right, bottom-left, bottom-right.
truss photo.jpg -o thumb.jpg --width 300 --height 300 --fit cover --position top-left# Crop a region (x, y, width, height) -- applied before resize
truss photo.jpg -o cropped.jpg --crop 100,50,400,300
# Rotate clockwise by any whole number of degrees
truss photo.jpg -o rotated.jpg --rotate 270
# Negative turns counter-clockwise, and angles past a full turn wrap:
# these three are the same rotation
truss photo.jpg -o a.jpg --rotate 270
truss photo.jpg -o b.jpg --rotate -90
truss photo.jpg -o c.jpg --rotate 630
# An angle that is not a multiple of 90 grows the canvas to fit the whole image,
# and fills the exposed corners with --background
truss photo.jpg -o tilted.jpg --rotate 45 --background 202020
# Background color as RRGGBB or RRGGBBAA hex (useful with contain or PNG alpha)
truss photo.jpg -o out.png --width 300 --height 300 --fit contain --background FF6B35FF| Original | Crop (--crop 100,50,400,300) |
Rotate (--rotate 270) |
Rotate (--rotate 45) |
Background (--background FF6B35FF) |
|---|---|---|---|---|
![]() |
![]() |
![]() |
![]() |
![]() |
A multiple of 90 only permutes pixels, so it stays exact. Any other angle resamples with
bilinear interpolation and expands the output to the rotated bounding box, so no corner is
cropped away. The exposed area takes --background; without it, transparent for formats
with an alpha channel and white for those without.
| Original | Gaussian Blur (--blur 5.0) |
Sharpen (--sharpen 3.0) |
Watermark |
|---|---|---|---|
![]() |
![]() |
![]() |
![]() |
# Gaussian blur (sigma 0.1 - 100.0)
truss photo.jpg -o blurred.jpg --blur 5.0
# Sharpen (sigma 0.1 - 100.0)
truss photo.jpg -o sharpened.jpg --sharpen 3.0
# Watermark with full control
truss photo.jpg -o watermarked.jpg \
--watermark logo.png \
--watermark-position bottom-right \
--watermark-opacity 50 \
--watermark-margin 10Watermark positions are the same as cover positions: center, top, right, bottom, left, top-left, top-right, bottom-left, bottom-right.
Note: the watermark overlay itself must be a raster image. The picture it goes onto may be an SVG, which is rasterized first; see the pipeline.
| Original | Grayscale (--grayscale) |
|---|---|
![]() |
![]() |
# Desaturate to grayscale
truss photo.jpg -o gray.jpg --grayscale
# Combine with other operations
truss photo.jpg -o thumb.jpg --width 320 --grayscaleLuminance uses the Rec. 601 weights, and the alpha channel is preserved. Desaturation runs
after resize, blur, and sharpen, and before the watermark, so a watermark keeps its own colors.
Anything --background filled in by then is part of the image and is desaturated with it, so
--fit contain --background ff0000 --grayscale produces gray padding, not red.
Unlike --blur and --sharpen, --grayscale also works for SVG input when the output is a
raster format; with svg output it is rejected, along with the other options that ask
for a different picture.
By default, truss strips all metadata for smaller and safer output.
| Flag | Behavior |
|---|---|
--strip-metadata (default) |
Remove all EXIF, ICC, and other metadata |
--keep-metadata |
Preserve EXIF, ICC, and all other supported metadata |
--preserve-exif |
Keep EXIF only, strip ICC and others |
--auto-orient (default) |
Apply EXIF orientation tag and reset it |
--no-auto-orient |
Skip EXIF orientation correction |
An optimization β truss optimize in any mode, or --optimize auto, lossless, or lossy
on convert β keeps the ICC profile even under --strip-metadata: dropping it would make the
re-encoded image render with shifted colors. A plain truss convert with no optimization
strips it as asked, and --preserve-exif drops it under every mode. Formats that cannot carry
a profile (BMP) strip it as asked.
Metadata support per output format:
| Format | ICC | EXIF | XMP | IPTC |
|---|---|---|---|---|
| JPEG | yes | yes | yes | yes |
| PNG | yes | yes | yes | no |
| WebP | yes | yes | yes | no |
| AVIF | yes | yes | no | no |
| BMP | no | no | no | no |
| TIFF | yes | no | no | no |
Asking to keep metadata an output format cannot carry is not silent: the request succeeds and
each kind that did not survive is reported as a warning: line on stderr, as a Truss-Warning
response header from the server, and in warnings from the Wasm package. AVIF keeps EXIF as an
item of its own and the profile as a colr property, both written into the container after the
encode; XMP would be a third item and IPTC has no place in the container, so those two are
reported as dropped like anywhere else.
# Keep all metadata (useful for archival)
truss photo.jpg -o out.jpg --keep-metadata
# Keep EXIF only (strip ICC profiles)
truss photo.jpg -o out.jpg --preserve-exif
# Disable auto-orientation
truss photo.jpg -o out.jpg --no-auto-orientUse - for input and/or output to integrate truss into shell pipelines. When reading from stdin, --format is required for output.
# Pipe from stdin to stdout
cat photo.png | truss - -o - --format jpeg > photo.jpg
# Download, convert, and upload in one pipeline
curl -s https://example.com/img.png | truss - -o - --format webp --width 800 | \
aws s3 cp - s3://bucket/thumb.webp
# Optimize after converting
truss photo.jpg -o - --format webp --optimize auto | cat > optimized.webptruss sanitizes SVG files by removing scripts and external references, making them safe for user-generated content.
# Sanitize SVG (remove scripts, external refs)
truss diagram.svg -o safe.svg
# Rasterize SVG to PNG at a specific width
truss diagram.svg -o diagram.png --width 1024Use --output= (or --output) to avoid ambiguity with filenames that start with a dash:
# Dash-prefixed output: use --output= to assign the value unambiguously
truss convert input.png --output=-output.jpg
# Dash-prefixed input: use a path prefix
truss convert ./-input.png -o out.jpgStart the server from an installed binary or a local build:
TRUSS_BEARER_TOKEN=changeme truss serve --bind 0.0.0.0:8080 --storage-root ./imagesOr run the published container image:
docker run -p 8080:8080 \
-e TRUSS_BIND_ADDR=0.0.0.0:8080 \
-e TRUSS_BEARER_TOKEN=changeme \
-e TRUSS_STORAGE_ROOT=/data \
-v "$(pwd)/images:/data:ro" \
ghcr.io/nao1215/truss:latestResize a local image to 400 px wide WebP in one request:
curl -X POST http://localhost:8080/images \
-H "Authorization: Bearer changeme" \
-F "file=@photo.jpg" \
-F 'options={"format":"webp","width":400}' \
-o thumb.webpIf you also want public signed URLs, start the server with signing keys and generate URLs with truss sign:
TRUSS_BEARER_TOKEN=changeme \
TRUSS_SIGNING_KEYS='{"mykey":"s3cret"}' \
truss serve --bind 0.0.0.0:8080 --storage-root ./images
truss sign --base-url http://localhost:8080 \
--path photos/hero.jpg \
--key-id mykey \
--secret s3cret \
--expires 1900000000 \
--width 800 \
--format webpUse truss validate to check server configuration without starting the process. See the API Reference for endpoint details and the Deployment Guide for Docker, storage backends, and production setup.
Node.js only. The signer uses node:crypto and should stay on the server side; do not ship the signing secret to browsers or Edge/browser runtimes.
npm install @nao1215/truss-url-signerimport { signPublicUrl } from "@nao1215/truss-url-signer";
const signedUrl = signPublicUrl({
baseUrl: "https://images.example.com",
source: {
kind: "path",
path: "hero.jpg",
},
transforms: {
width: 1200,
format: "webp",
},
keyId: "public-demo",
secret: process.env.TRUSS_SIGNING_SECRET ?? "",
expires: Math.floor(Date.now() / 1000) + 300,
});See packages/truss-url-signer for the full API and examples for both /images/by-path and /images/by-url.
truss also ships a browser-oriented WASM adapter for local, client-side image processing. The generated package exposes a small JS-facing API over the same Rust core used by the CLI and HTTP server.
For bundler-based browser apps, the repository includes the source for the official npm package in packages/truss-wasm. It uses the fixed feature set wasm,svg,avif, so AVIF is available and WebP stays lossless in the package build.
npm install @nao1215/truss-wasmIf you want a minimal consumer you can run immediately, see examples/vite-truss-wasm.
import {
getCapabilitiesJson,
inspectImageJson,
transformImage,
} from "@nao1215/truss-wasm";
const inputBytes = new Uint8Array(await file.arrayBuffer());
const capabilities = JSON.parse(getCapabilitiesJson());
const inspected = JSON.parse(inspectImageJson(inputBytes, undefined));
const result = transformImage(
inputBytes,
undefined,
JSON.stringify({
format: "jpeg",
width: 1200,
quality: 80,
}),
);The official npm package wraps the raw browser bindings and initializes the Wasm module at import time, so consumer code does not call init() explicitly.
For maintainers:
node ./scripts/run-wasm-consumer-smoke.mjspacks the local npm artifact, installs it into a throwaway consumer, and runs one real transform through the published JS surface.node ./scripts/run-wasm-vite-example-smoke.mjsrewires the Vite example to the local tarball and verifies that a real bundler build still succeeds.node ./scripts/run-wasm-vite-example-runtime-smoke.mjsverifies the checked-in Vite example and confirms the browser runtime path in headless Chrome.
The example below assumes your page is served from a directory that also contains pkg/truss.js. When using ./scripts/build-wasm-demo.sh, that means web/dist/index.html importing ./pkg/truss.js.
import init, {
getCapabilitiesJson,
inspectImageJson,
transformImage,
} from "./pkg/truss.js";
await init();
const inputBytes = new Uint8Array(await file.arrayBuffer());
const capabilities = JSON.parse(getCapabilitiesJson());
const inspected = JSON.parse(inspectImageJson(inputBytes, undefined));
const result = transformImage(
inputBytes,
undefined,
JSON.stringify({
format: "jpeg",
width: 1200,
quality: 80,
}),
);
const response = JSON.parse(result.responseJson);
const outputBlob = new Blob([result.bytes], {
type: response.artifact.mimeType,
});The GitHub Pages demo is intentionally built with wasm,svg. The official npm package uses wasm,svg,avif. Check capabilities at runtime and see WASM Integration for package and raw-build usage, feature differences, import-path assumptions, API shapes, constraints, limits, and error handling.
| Command | Description |
|---|---|
convert |
Convert and transform an image file (can be omitted; see above) |
optimize |
Optimize an image with format-aware auto/lossless/lossy modes (truss optimize photo.jpg -o photo-optimized.jpg --mode auto) |
inspect |
Show metadata (format, dimensions, EXIF orientation, alpha, animation) of an image |
serve |
Start the HTTP image-transform server (implied when server flags are used at the top level) |
validate |
Validate server configuration without starting the server (useful in CI/CD) |
sign |
Generate a signed public URL for the server |
completions |
Generate shell completion scripts |
help |
Show help for a command (for example truss help convert) |
Top-level shortcuts:
truss <INPUT> -o <OUTPUT> [OPTIONS]is implicitconverttruss --bind <ADDR> [OPTIONS]is implicitservetruss --versionprints version information
Which of these names a version number covers is stated under Compatibility, beside the exit code table it shares the promise with.
| Page | Description |
|---|---|
| Configuration Reference | Environment variables, storage backends, logging, and all server settings |
| API Reference | HTTP endpoints, request/response formats, CDN integration |
| Signed URL Specification | Canonicalization rules, compatibility policy, and SDK guidance for public signed URLs |
| Deployment Guide | Docker, prebuilt binaries, cloud storage (S3/GCS/Azure), production setup |
| Development Guide | Building from source, testing, benchmarks, WASM demo, contributing |
| WASM Integration | Browser build flags, JS API contract, runtime capabilities, limits, and caveats |
| Prometheus Metrics | Metrics reference, bucket boundaries, example PromQL queries |
| Pipeline Order | Transform stage order and SVG-specific constraints |
| OpenAPI Spec | Machine-readable API specification |
| Problem Types | Every type an HTTP error body can carry, with its status and when it is sent |
flowchart TB
CLI["CLI<br/>(truss convert)"] --> Core
Server["HTTP Server<br/>(truss serve)"] --> Core
WASM["WASM<br/>(browser)"] --> Core
subgraph Core["Shared Rust core"]
direction LR
Sniff["Detect format"] --> Transform["Crop / resize / blur / sharpen / watermark"]
Transform --> Encode["Encode output"]
end
Server --> Storage
subgraph Storage["Storage backends"]
FS["Local filesystem"]
S3["S3"]
GCS["GCS"]
Azure["Azure Blob"]
end
CLI reads local files or fetches remote URLs directly. The HTTP server resolves images from storage backends or client uploads. The WASM build processes files selected in the browser.
Benchmarks are defined in benches/transform.rs. The numbers below were measured with criterion on a single core using the sample image in this repository. Use them as a local reference and run just bench on your hardware to compare.
| Operation | Time |
|---|---|
| JPEG -> PNG | 8.2 us |
| JPEG -> WebP (q 80) | 37 us |
| JPEG -> AVIF (q 80) | 242 us |
| Resize 100 x 100 (cover) | 317 us |
| Resize 800 x 600 (cover) | 11.4 ms |
| Resize 1920 x 1080 (cover) | 68 ms |
| Blur (sigma 5.0) | 6.8 us |
| Sharpen (sigma 3.0) | 5.9 us |
| SVG sanitize (passthrough) | 386 ns |
| SVG -> PNG 1024 w rasterize | 649 us |
Format detection (sniff) |
18 ns |
Feature comparison with imgproxy and imagor as of March 2026. Check upstream documentation if you need to confirm a specific feature before migrating or standardizing on a tool.
| Feature | truss | imgproxy | imagor |
|---|---|---|---|
| Language | Rust | Go | Go |
| Runtime dependencies | None | libvips (C) | libvips (C) |
| CLI | Yes | No | No |
| WASM browser demo | Yes | No | No |
| Signed URLs | Yes | Yes | Yes |
| JPEG / PNG / WebP / AVIF | Yes | Yes | Yes |
| JPEG XL (JXL) | No | Input only | Yes |
| TIFF | Yes | Yes | Yes |
| GIF (static) | Input only | Yes | Yes |
| GIF animation processing | No (out of scope) | Yes | Yes |
| SVG sanitization | Yes | Yes | No |
| Smart crop | No | Yes | Yes |
| Sharpen filter | Yes | Yes | Yes |
| Crop / Trim / Padding | Yes | Yes | Yes |
| S3 | Yes | Yes | Yes |
| GCS | Yes | Yes | Yes |
| Azure Blob Storage | Yes | Yes | No |
| Watermark | Yes | Yes | Yes |
| Prometheus metrics | Yes | Yes | Yes |
| License | MIT | Apache 2.0 | Apache 2.0 |
See the public roadmap for planned features and milestones.
Contributions are welcome. See CONTRIBUTING.md for details.
- Look for
good first issueto get started. - Report bugs and request features via Issues.
- If the project is useful, starring the repository helps.
- Support via GitHub Sponsors is also welcome.
- Sharing the project on social media or in blog posts is appreciated.
Thanks goes to these wonderful people (emoji key):
CHIKAMATSU Naohiro π» π |
Mayank Gupta π€ |
Rafael Baboni Dominiquini π¦ |
Released under the MIT License.


















