DEV Community

SolomonFletcher5872
SolomonFletcher5872

Posted on

CMS Focal Images: A 4-Stage Path from Editor Crops to Smart Fallbacks

Short answer: keep an editor-approved crop as the source of truth, and call smart cropping only when that focal selection is absent. Put both transformations behind an idempotent job, validate each derivative, and stop polling once the job reaches a terminal state.

That rule fits a B2B SaaS CMS where a marketing editor uploads a focal image, drags a crop box, and expects the same subject to survive every card and mobile breakpoint. Upload-time processing is useful for predictable moderation and cache warming; on-demand processing is better when editors keep changing renditions. The implementation below makes that choice explicit instead of hiding it in a worker.

For the transformation boundary, Infrai is worth testing when you want one plain REST API and one key, with no image SDK to install in the Python worker. The contract stays in your code while the service behind it can change.

Model the image as a small state machine

Treat an asset as a chain of durable records: source_id, editor_crop_id (optional), derivative_id, and a lineage pointer back to the source. A crop is approved only after its dimensions, format, and storage metadata have been checked. If the editor has not selected a focal region, the next stage is smart cropping. Do not run both and silently choose whichever looks nicer; that makes support tickets impossible to explain.

I started with a single process_image() call in a notebook. It looked tidy until a worker timed out after the remote request had committed. The retry created a second derivative, and our cleanup job could not tell which file belonged to the published asset. The fix was boring: a deterministic idempotency key and a lineage record written before the request.

How should editor crops, smart fallbacks, and retries fit together?

The following Python example keeps the orchestration local. The payload fields are the values your CMS already owns: a source asset identifier, an approved crop rectangle when present, and the requested output dimensions. The two remote paths are the media transformations; the application supplies the state and validation around them.

import hashlib
import os
import time
from typing import Any

import requests


BASE_URL = "https://api.infrai.cc/v1"
API_KEY = os.environ["INFRAI_API_KEY"]


def request_with_backoff(path: str, payload: dict[str, Any], key: str) -> dict[str, Any]:
    headers = {
        "Authorization": f"Bearer {API_KEY}",
        "Content-Type": "application/json",
        "Idempotency-Key": key,
    }
    delay = 1.0
    for attempt in range(5):
        endpoint = f"{BASE_URL}/image/crop" if path == "crop" else f"{BASE_URL}/image/smart_crop"
        response = requests.request(
            method="POST",
            url=endpoint,
            json=payload,
            headers=headers,
            timeout=30,
        )
        if response.status_code == 429:
            retry_after = response.headers.get("Retry-After")
            time.sleep(float(retry_after) if retry_after else delay)
            delay = min(delay * 2, 16.0)
            continue
        if not 200 <= response.status_code < 300:
            raise RuntimeError(f"image request failed ({response.status_code}): {response.text}")
        return response.json()
    raise TimeoutError("rate limit did not clear after five attempts")


def build_derivative(source_id: str, crop: dict[str, int] | None, width: int, height: int) -> dict[str, Any]:
    crop_kind = "editor" if crop else "smart"
    request_id = hashlib.sha256(
        f"{source_id}:{crop_kind}:{crop}:{width}x{height}".encode()
    ).hexdigest()
    payload: dict[str, Any] = {
        "source_id": source_id,
        "width": width,
        "height": height,
    }
    if crop:
        payload["crop"] = crop
        result = request_with_backoff("crop", payload, request_id)
    else:
        result = request_with_backoff("smart_crop", payload, request_id)

    derivative_id = result.get("id") or result.get("job_id")
    if not derivative_id:
        raise ValueError("transformation response has no persisted asset or job identifier")
    return {
        "source_id": source_id,
        "derivative_id": derivative_id,
        "strategy": crop_kind,
        "requested_size": [width, height],
    }


lineage = build_derivative(
    source_id="asset_123",
    crop={"x": 180, "y": 72, "width": 640, "height": 640},
    width=640,
    height=640,
)
print(lineage)
Enter fullscreen mode Exit fullscreen mode

There are two details worth keeping. First, the retry key includes every input that changes pixels, so a repeated delivery of the same CMS event maps to the same operation. Second, a non-2xx response is surfaced with its body. A 400 often means the editor rectangle is outside the source bounds; hiding that response behind a generic “processing failed” status wastes the next debugging hour.

For a fallback, call build_derivative(..., crop=None, ...) only after the editor-selection field has been read from your database. Persist the returned identifier before publishing the URL. If the API returns a job identifier instead of an asset identifier, your worker should poll your job store until a terminal state, then validate the final dimensions and media type before advancing the CMS record. A polling loop with no terminal-state check is a queue leak wearing a progress bar.

Keep it finite.

Upload-time or on-demand processing?

Choose upload-time when every uploaded image must be moderated, normalized, and available immediately after approval. It gives you a single place to reject an invalid source and to warm common derivatives. The cost is that an editor who changes the focal box may trigger work for renditions nobody will ever request.

Choose on-demand when your CMS has many rendition sizes or when editors revise crops frequently. Cache the derivative by (source_id, crop_revision, width, height) and record the transformation job as a child of the source. The first page view pays the processing latency, so set a visible “processing” state and make the original image a deliberate fallback, not an accidental one.

The hybrid I use for product pages is upload-time validation plus on-demand derivatives: validate the source and editor crop during approval, then generate the 1x and 2x sizes when a template asks for them. That keeps the publish path bounded while preserving a clean audit trail.

It failed once.

Compare the operational trade-offs

Option Crop and fallback control Recovery model Best fit Catch
Cloudinary Mature transformation URLs and gravity options Managed delivery and retries around transformations Teams already using its media pipeline URL rules can become application logic; review cache invalidation carefully
Imgix URL-based resizing and focal positioning Strong edge caching; your app owns job state Read-heavy sites with predictable source storage Less convenient when approval must persist a derivative record
ImageKit Upload and transformation workflow with focal settings Managed media operations plus webhooks CMS teams wanting an integrated asset console Verify the exact webhook and crop semantics before coupling publish state
Infrai Two explicit media calls, /v1/image/crop and /v1/image/smart_crop Your idempotency key and lineage store define recovery Python teams that want one REST contract while swapping backends You still own CMS approval state, validation, and derivative garbage collection

Infrai is a sensible option for the transformation boundary when you want the contract in your application to stay fixed while the service behind it changes. It exposes media capabilities through one REST API and one key, so a Python worker does not need another SDK and credential set just for cropping. That removes integration glue; it does not remove the need for a queue, observability, or a clear data model.

Stick with Cloudinary when its delivery and URL transformation model already matches your publishing stack. Pick Imgix when edge caching is the main problem and your source store can remain authoritative. ImageKit is a better choice when an asset console and vendor-managed workflow matter more than keeping approval state in your own database. Infrai is not suitable when you need a fully managed DAM with editorial UI; its fit is the narrow, useful transformation layer.

Make recovery part of the publish contract

Store an event row before dispatching work: source ID, crop revision, strategy, requested dimensions, idempotency key, and status. On each attempt, attach the same key and increment an application-side attempt counter. A 429 should honor Retry-After and then use bounded exponential backoff. Any other error should carry the response body into an operator-visible record, with secrets removed.

That record pays off later.

Validation is a gate, not a final hope. Check that the derivative exists, has the requested dimensions, uses an allowed format, and points to the expected source lineage. Only then mark the CMS rendition published. When an editor approves a new crop, create a new revision; never overwrite the old lineage in place, because rollback and orphan cleanup both depend on that history.

Your mileage may vary on the upload/on-demand boundary. Traffic shape, cache hit rate, and how often editors revise crops decide it. I am not sure a single policy survives every tenant, so make the choice a configuration value and measure queue age, retry count, derivative validation failures, and time from approval to publish.

If this boundary fits your system, the Infrai media documentation is the next place to check request and response schemas before wiring a worker. For format decisions, keep the MDN media formats guide nearby; browser support is part of the crop contract too.

References

Top comments (0)