Features

Markdown

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. File HTTP: Public CMS API — Files. Admin browse: File manager. Disks: Files & storage.

Named presets

Configure (admin)

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

KeyMeaning
allowed_transformationsWhich transform operations are enabled (defaults include thumbnail)
preset_transformationsOrdered list of named presets edited by TransformPresetsField

Each preset has:

FieldNotes
keyURL segment and map key (thumbnail, card, …). Unique, non-empty.
fitOne of project transform_fits: contain, cover, inside, outside
width / heightTarget box in pixels (nullable depending on fit)
qualityEncode quality (default 82)
without_enlargementWhen true, never upscale beyond the source
formatauto (prefer source-friendly encode) or an explicit output format

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

keyfitsize
thumbnailcontain128×128
smallcontain64×64
mediumcontain256×256
cardinside512×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:

{
  "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:

# 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.

No query-string geometry

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

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.

// 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
// 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):

// 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 does not ship this component yet. Copy the snippet into your app, or open a follow-up if you want it upstream.

See also

Previous
File manager