---
# Athena Lab listing metadata. Not part of the blueprint; ignore when building.
title: Image and Video Transformation Server
summary: Self-hosted, Cloudinary-style engine. Upload images or videos, define output targets (size, format, fit), and get every combination back. Batch, caching, SSRF-safe, with an HTTP API and a browser UI.
description: "A self-hosted image and video transformation service. One upload yields many derivatives across the cross product of sources and targets, with content-hash caching, partial-success batches, poster-frame extraction from video, animated-GIF-aware resizing, an async job path for heavy work, and a hardened security posture (real-content verification, decode-bomb and SSRF guards). It exposes an HTTP API and a browser upload UI driven by the same engine."
version: 1.1.0
category: Media & File Processing
platforms: [Server]
licence: MIT
stack: [Node.js, Fastify, sharp, ffmpeg, local or S3-compatible storage]
tested_with: [Claude Code]
tags: [images, video, resize, reformat, transcode, thumbnails, webp, avif, mp4, webm, gif, batch, api, ssrf-safe]
---

# Image and Video Transformation Server

A self-hosted service that takes one or more source images or videos and returns
each one rendered into one or more output targets (a size, a format, a fit). One
upload, many derivatives. A Cloudinary-style transform engine you own.

It exposes an HTTP API and a browser upload UI driven by the same engine.

> This file is a **blueprint**, not source code. Hand it to an AI coding agent to
> generate the application. The stack named above is a sensible default, not a
> requirement: tell the agent to swap any part ("use Python and Pillow", "use
> Bun", "store on Cloudflare R2") and the rest of the blueprint still holds,
> because it is written as intent, not implementation. A reference implementation
> is sketched at the end for anyone who wants one.

---

## 1. Who it is for and why it exists

The buyer is a developer or team that needs product images and short videos in
many shapes (thumbnails, hero images, avatars, web-ready clips, poster frames)
and does not want to wire up ImageMagick and ffmpeg by hand, reason about decode
bombs and SSRF, or pay a per-image SaaS. They want a small, ownable service they
can run behind their own app.

The value is not "resize an image", which is a one-liner. The value is the
hundred decisions around it: the request shape, partial failure, caching, the
sync/async boundary, the security posture, and the awkward edges (AVIF reports
as HEIF, a GIF is both an image and a video). Those are what this blueprint
encodes.

## 2. Core concept: sources times targets

A request carries **N sources** and **M targets**. The service produces the full
cross product: every source rendered into every target, so N x M results.

A **target** is a named recipe. The same target applied to different sources
yields consistently named outputs, which is what makes batch work predictable.

**Partial success is a rule, not an accident.** One unreadable source, or one
impossible target, produces an error entry in its own slot and never fails the
rest of the batch. A caller always gets back a result for every slot, each either
a produced asset or a structured error.

## 3. Sources

Two ways to supply sources, treated identically once ingested:

- **Uploaded files** (multipart).
- **Remote URLs** (a JSON body of `http(s)` links the service fetches itself).

Every source is validated as a real, decodable image or video by inspecting its
actual bytes, never by trusting a file extension or a client-declared MIME type.

## 4. Targets

A target object:

| field | meaning | default |
|-------|---------|---------|
| `name` | label echoed back on the result | `target_N` |
| `format` | output format (see below) | keep the source's kind |
| `width` / `height` | either, both, or neither; positive integers | auto |
| `fit` | `contain`, `cover`, `fill`, `inside`, `outside` | `inside` |
| `quality` | 1 to 100; for video this maps to a CRF | per-format default |
| `fps` | 1 to 60; video and gif targets only | source rate |
| `strip` | drop EXIF and metadata (image targets) | true |

Formats:

- **Image**: `webp`, `avif`, `jpeg`, `png`.
- **Video**: `mp4`, `webm`, `gif`.

Omitting `format` means "keep the source's kind": an image keeps its own format,
a video defaults to `mp4`, an animated gif stays a gif.

## 5. Media routing (the load-bearing decision)

Source kind is detected from the bytes. Target kind follows the requested format.
GIF is deliberately treated as dual, because it genuinely is both a still format
and an animation container. The routes:

- **still image to image** (including gif): rendered by the image engine. A gif
  target from a still image gives a static gif.
- **animated gif or video to mp4 / webm / gif**: transcoded and scaled by the
  video engine, with animation preserved. "Keep source" on a gif stays a gif.
- **video to image** (webp, png, and so on): a single poster frame is extracted,
  then run through the ordinary image path, so every image option applies to it.
- **still image to mp4 / webm**: refused. You cannot make a real video from one
  still. Only these two true video containers are refused from a still; gif is
  allowed.

The subtle case that must be handled: **an animated gif looks like an image by
its magic bytes but must be processed as a video.** Detect multi-frame sources
(frame or page count greater than one) and route them to the video engine so a
resize does not silently flatten the animation to a single frame.

## 6. Processing model: sync fast path, async for heavy work

- **Small, image-only batches** run synchronously and return results in the
  response. This is the common case and must feel instant.
- **Anything touching video**, or a batch over the size thresholds, becomes a
  **job**: the request returns immediately with a job id, and the caller polls a
  job endpoint until the status is done or failed. Video transcoding is measured
  in seconds, so it must never hold a request open.

Video work is file-based (write the source to a temp file, transcode to a temp
file, then store), because video is too large to shuttle through memory the way
images are. The transcoder runs behind a **concurrency limit** so a burst of
jobs cannot exhaust the machine, and every transcode has a wall-clock timeout and
is killed if it overruns.

## 7. Caching and idempotency

Every unit of work has a key derived from the source bytes plus the normalised
recipe. Identical work is never repeated: a repeat request returns the cached
asset and marks it as cached. This is the entire performance story and should be
stated plainly rather than bolted on.

Produced assets are addressed by a content hash, so identical output is stored
once and an asset URL is never a guessable sequential id.

## 8. Storage

Storage sits behind a tiny driver (store, exists, path-for, sweep) so the default
local-disk implementation can be replaced with S3-compatible object storage
without touching any caller.

Produced assets carry a TTL and are swept lazily. Source uploads are never
persisted beyond the request except as the derivative that caching keeps.

## 9. API surface

Described by intent; an agent should choose idiomatic routing for the chosen
framework.

- **Transform** (`POST`): accepts multipart files plus a `targets` field, or a
  JSON body of source URLs plus targets. Returns, per source per target: an id,
  the format, final width and height, byte size, and a cached flag. Over the sync
  thresholds it returns a job id instead.
- **Result fetch** (`GET` by asset id): returns the produced asset with the right
  content type, cacheable and immutable.
- **Job status** (`GET` by job id): the same result shape as transform once done.
- **Bundle** (`POST`): given a list of asset ids and desired filenames, streams a
  single zip. Ids are validated and names sanitised before any bytes stream, so a
  caller cannot read arbitrary files or escape the archive.
- **Health** (`GET`): liveness.

Every produced asset is reachable by a stable, direct URL so an agent or a
browser can fetch it without scraping.

Example transform (multipart):

```
POST /transform
  files=@photo.jpg
  targets=[
    { "name": "thumb", "format": "webp", "width": 320 },
    { "name": "hero",  "format": "avif", "width": 1200, "fit": "cover" }
  ]
```

Example response:

```json
{
  "status": "done",
  "sources": [
    { "source": "photo.jpg", "status": "ok", "results": [
      { "target": "thumb", "status": "ok", "id": "<hash>.webp",
        "format": "webp", "width": 320, "height": 240, "size": 8462, "cached": false }
    ]}
  ]
}
```

## 10. Security (do not skip; this is half the value)

- **Verify real content.** Decode an image header to confirm it is a real, allowed
  image. Probe a video to confirm it is a real video and to enforce a maximum
  duration. Never trust the extension or the client MIME.
- **Decode-bomb guard.** Cap the decoded pixel count before a full image decode,
  however small the compressed bytes.
- **SSRF on URL sources.** Only `http` and `https`. Resolve the host and refuse
  private, loopback, link-local, and reserved address ranges (this is what blocks
  cloud metadata endpoints). Cap redirects, fetch bytes, and fetch time.
- **Never hand ffmpeg a user string.** The transcoder only ever sees a local temp
  file the service wrote, never a user URL or protocol string, which closes
  ffmpeg's file and protocol read tricks. Every transcode is timed out and killed
  if it overruns.
- **Bundle safety.** Validate every asset id against storage and sanitise every
  filename before streaming a zip.
- **Optional API keys.** Off by default so local use and the UI work immediately;
  when keys are configured, require a bearer token on the transform endpoint.

## 11. Limits and configuration

Every limit is configuration, not code: max image file size, max video file size,
max sources per request, max targets per request, max output dimension (separate
caps for image and video), max input pixels, max video duration, the sync
thresholds, transcode timeout, transcode concurrency, and result TTL. Ship sane
defaults (for example: images to 10 MB, videos to 200 MB, 20 sources, 10 targets,
video duration to 60 seconds).

## 12. Browser UI

A single page, backed by the same engine as the API:

- Drag-drop or pick images and videos.
- Build a list of targets (format, width, height, fit, quality, fps).
- Run, and see every result as a grid: images as thumbnails, videos as inline
  players, gifs animating. Each result shows its format, dimensions, and size,
  with a download link and a cached badge.
- A **download-all** button that zips every result via the bundle endpoint.
- An **API reference** panel so a developer can move from the UI to the API.

The UI must be server-rendered enough to work without a heavy client framework;
it is a small tool, not an application.

## 13. Out of scope (v1)

Watermarking, smart or content-aware cropping, face detection, audio-only
transforms, building a video from a single still, and Cloudinary-style signed
on-the-fly URL transforms (`/w_200,f_auto/...`). Named so the boundary is clear.

## 14. Decisions worth keeping (the why)

- **Cross product with per-slot partial success** is the shape that makes batch
  work usable; a single bad input must not sink the batch.
- **Caching is keyed on source-plus-recipe, and assets are content-addressed.**
  Two separate ideas: never redo work, and never store the same bytes twice.
- **Sync for images, async for video** is the one boundary that keeps the common
  case instant and the heavy case from blocking. Do not try to make video sync.
- **Model at the mess boundary, deterministic tools for facts.** Sniff and decode
  to learn what a file actually is; do not trust what the client says it is.
- **GIF is dual.** Most bugs in this class of service come from treating gif as
  purely one thing. It is both. Detect animation and route accordingly.
- **The image library only appears in one module; the video tool in one other.**
  Everything else (request parsing, routing, caching, storage, jobs) is engine
  agnostic, which is what lets a downloader swap either engine cheaply.

## 15. Reference implementation (appendix, one way to build it)

Not prescriptive. This is the shape the default stack produced, useful as a
starting point.

Default stack: Node.js with Fastify, the `sharp` library for images, a bundled
`ffmpeg` and `ffprobe` for video (via `ffmpeg-static` / `ffprobe-static`, so
there is nothing to install), local disk storage, Tailwind-free vanilla UI.

Suggested module split (each name is a concern, not a mandate):

- config: every limit and threshold, environment-overridable.
- targets: validate and normalise one recipe up front.
- media: magic-byte kind sniffing (image vs video) and format-to-kind mapping.
- image transform: the only place the image library is touched.
- video: the only place ffmpeg is touched (probe, transcode, frame extract), plus
  the concurrency limit and per-run timeout.
- storage: content-hash addressing and the pluggable driver.
- fetch-source: the guarded URL fetch.
- pipeline: the cross product, media routing, partial success, caching.
- jobs: the async store (swap for a real queue without changing the API).
- request: normalise multipart or JSON into uniform sources.
- server: routes, optional auth, error mapping, and the UI.

Edges the reference build hit, worth pre-empting in any stack:

- On some platforms a file URL's path needs proper decoding before use as a
  filesystem path, or you get a mangled directory.
- Some AVIF encoders report the output format as `heif` (AVIF is HEIF-family);
  normalise it back to `avif` for filenames and content types.
- Zip libraries change APIs across major versions; confirm the constructor.
- An animated gif's magic bytes say "image"; you must inspect frame count to know
  it is really animation, then route it as video.
