# Image transforms

Source: https://docs.qiick.io/externa/image-transforms

Externa generates image derivatives on demand with PHP GD (`FileTransformService`). Operators define **named presets** under Settings → Files & Media; editors set a **focal point** per file so `cover` crops keep the subject. Headless clients consume preset URLs from the Public CMS API.

> **Related**
> Configure presets: [Project settings — Files & Media](https://docs.qiick.io/externa/project-settings#files-and-media-settings-project-files). File HTTP: [Public CMS API — Files](https://docs.qiick.io/externa/public-cms-api#files). Admin browse: [File manager](https://docs.qiick.io/externa/file-manager). Disks: [Files & storage](https://docs.qiick.io/externa/files-configuration).

## Named presets

### Configure (admin)

Open **Settings → Files & Media** (`/settings/project/files`). The form stores:

| Key                       | Meaning                                                               |
| ------------------------- | --------------------------------------------------------------------- |
| `allowed_transformations` | Which transform operations are enabled (defaults include `thumbnail`) |
| `preset_transformations`  | Ordered list of named presets edited by `TransformPresetsField`       |

Each preset has:

| Field                 | Notes                                                                    |
| --------------------- | ------------------------------------------------------------------------ |
| `key`                 | URL segment and map key (`thumbnail`, `card`, …). Unique, non-empty.     |
| `fit`                 | One of project `transform_fits`: `contain`, `cover`, `inside`, `outside` |
| `width` / `height`    | Target box in pixels (nullable depending on fit)                         |
| `quality`             | Encode quality (default **82**)                                          |
| `without_enlargement` | When true, never upscale beyond the source                               |
| `format`              | `auto` (prefer source-friendly encode) or an explicit output format      |

Fresh installs ship these defaults (`config/settings.php`):

| key         | fit       | size    |
| ----------- | --------- | ------- |
| `thumbnail` | `contain` | 128×128 |
| `small`     | `contain` | 64×64   |
| `medium`    | `contain` | 256×256 |
| `card`      | `inside`  | 512×512 |

Saving the form updates project settings; new requests resolve presets through `ProjectSettingsService::transformPreset($key)`.

### Public API URLs

For an image file, `GET /api/v1/files/{id}` (and list / item `?include=files` expanders) includes a `transforms` map built by `FileTransformUrlBuilder` — one entry per configured preset key:

```json
{
  "url": "http://externa-core.test/api/v1/files/12/content",
  "transforms": {
    "thumbnail": "http://externa-core.test/api/v1/files/12/transforms/thumbnail",
    "small": "http://externa-core.test/api/v1/files/12/transforms/small",
    "medium": "http://externa-core.test/api/v1/files/12/transforms/medium",
    "card": "http://externa-core.test/api/v1/files/12/transforms/card"
  }
}
```

Fetch a derivative:

```bash
# Named preset
curl -sS -OJ http://externa-core.test/api/v1/files/12/transforms/card \
  -H "Authorization: Bearer ek_YOUR_SECRET"

# Ad-hoc edge (optional): size-{n}, clamped to FileTransformService::MAX_SIZE (512)
curl -sS -OJ http://externa-core.test/api/v1/files/12/transforms/size-256 \
  -H "Authorization: Bearer ek_YOUR_SECRET"
```

Path shape: **`GET /api/v1/files/{id}/transforms/{key}`**.

- `{key}` = a project preset key → `ensureTransform(..., key: $key)`
- `{key}` = `size-{n}` (digits) → `ensureThumbnail($file, n)` (legacy / ad-hoc edge)
- Unknown key or non-image → **404**

Auth matches file content: role needs **Files → Read** (and **Read private** for effective-private files). Anonymous requests use the `public` role — grant Read there for marketing sites so `<img>` / Next `<Image>` can hit transform URLs without a Bearer header. See [Public vs private files](https://docs.qiick.io/externa/public-cms-api#public-vs-private-files).

> Width, height, fit, and quality are **not** taken from the request URL. Change them in Settings → Files & Media (or use `size-{n}` for a simple max edge). There is no Cloudinary-style `?w=&h=` query API.

### Caching behaviour

- First request for a preset (or `size-{n}`) runs GD, writes a cache object on the file’s disk, then streams it.
- Response header: `Cache-Control: private, max-age=86400`.
- Cache path includes the normalized preset + focal point. Changing `focal_point_x` / `focal_point_y` (or replacing the binary) clears existing transforms so the next request regenerates.
- Presets larger than the ad-hoc `MAX_SIZE` (512) ceiling are allowed for **named** keys; `size-{n}` remains capped.

## Focal point

Normalized coordinates on the file row: **`focal_point_x`** and **`focal_point_y`** in **0–1** (default centre `0.5` / `0.5` when unset).

### Set in admin

File Manager → open an image's details panel → **click the preview** where the subject is (1.4.0; arrow keys nudge the point by 1%, Shift by 10%, Enter sets it). A crosshair marks the point and **Reset to centre** clears it. The panel saves it through the metadata update (`PATCH /files/{file}`, `focal_point_x` / `focal_point_y` in `[0, 1]`). Only images have a focal point; folders and other files do not show it. Before 1.4.0 the panel had X / Y number fields and Translate / Scale fields, which are removed.

### Set via Public CMS API

```bash
curl -sS -X PATCH http://externa-core.test/api/v1/files/12 \
  -H 'Accept: application/json' \
  -H 'Content-Type: application/json' \
  -H "Authorization: Bearer ek_YOUR_SECRET" \
  -d '{"focal_point_x":0.35,"focal_point_y":0.6}'
```

Requires Files **update**. Response FileResource echoes the new focal fields. Metadata update clears cached transforms for that file.

### Effect on `cover` crops

`FileTransformService` injects the file’s focal into every preset before geometry. For **`fit: cover`**, the crop window is placed so the focal stays near the centre of the output (clamped to edges) — faces / subjects stay in frame when the aspect ratio changes. Other fits (`contain`, `inside`, `outside`) still record focal in the cache key; cover is the geometry that uses it for crop offset.

## Next.js `<Image>` example

Use the preset URL from the FileResource as the `src`. Prefer anonymous **public** Read for browser-loaded transforms (no API key in the client). Configure `images.remotePatterns` (or a custom loader) for your Externa host.

```tsx
// next.config.ts — allow the Externa origin
import type { NextConfig } from 'next'

const externaHost = new URL(
  process.env.EXTERNA_API_URL ?? 'http://externa-core.test',
).hostname

const nextConfig: NextConfig = {
  images: {
    remotePatterns: [
      {
        protocol: 'http', // use https in production
        hostname: externaHost,
        pathname: '/api/v1/files/**',
      },
    ],
  },
}

export default nextConfig
```

```tsx
// components/ExternaImage.tsx
import Image, { type ImageLoaderProps } from 'next/image'

/** Pass-through loader: Externa already encodes size via the preset key. */
function externaLoader({ src }: ImageLoaderProps): string {
  return src
}

type Props = {
  fileId: number
  /** Preset key from Settings → Files (e.g. card, medium, thumbnail) */
  preset?: string
  alt: string
  width: number
  height: number
  className?: string
  /** Absolute Externa origin, no trailing slash */
  apiOrigin?: string
}

export function ExternaImage({
  fileId,
  preset = 'card',
  alt,
  width,
  height,
  className,
  apiOrigin = process.env.NEXT_PUBLIC_EXTERNA_API_URL ??
    'http://externa-core.test',
}: Props) {
  const src = `${apiOrigin.replace(/\/$/, '')}/api/v1/files/${fileId}/transforms/${preset}`

  return (
    <Image
      loader={externaLoader}
      src={src}
      alt={alt}
      width={width}
      height={height}
      className={className}
      // unoptimized optional if you only need the CDN/Externa encode
    />
  )
}
```

Usage with data already fetched server-side (`?include=files`):

```tsx
// Prefer the URL Externa already built on the FileResource:
const hero = item.data.cover // expanded file object
;<Image
  loader={({ src }) => src}
  src={hero.transforms.card}
  alt={hero.title ?? hero.filename}
  width={hero.width ?? 512}
  height={hero.height ?? 512}
/>
```

> **Starter scope**
> Official [externa-next-starter](https://github.com/qiick-io/externa-next-starter) does not ship this component yet. Copy the snippet into your app, or open a follow-up if you want it upstream.

## See also

- [Public CMS API — Files](https://docs.qiick.io/externa/public-cms-api#files)
- [Project settings — Files & Media](https://docs.qiick.io/externa/project-settings#files-and-media-settings-project-files)
- [File manager](https://docs.qiick.io/externa/file-manager)
- [Webhook rebuild & CDN purge](https://docs.qiick.io/externa/webhook-rebuild-purge) (core [#37](https://github.com/qiick-io/externa-core/issues/37))
