DEV Community

BrantLockwood468
BrantLockwood468

Posted on

Content-Aware Property Photos: Store 3 Aspect Ratios at Ingest

A Node.js property service needs a content-aware crop for multiple aspect ratios because one listing photo must fit search results, map cards, and a wide detail-page hero. That constraint decides the architecture.

Short answer: smart-crop each uploaded photo once for every aspect ratio the application renders, store those files, and keep the untouched original. Do not repeat the crop on every request. This makes delivery latency predictable and preserves a source for the next layout nobody has designed yet.

How should Node.js content-aware crop handle multiple aspect ratios?

Before: a browser asks for unit-42.jpg?w=640&h=360, an image service chooses a crop, and the result may need computation or a cache fill. Multiply that by three slots, several screen densities, and cache eviction. Storage stays lean, but delivery work and cache behavior enter the request path.

After: the upload handler accepts one original, creates the known shapes, and records their keys. Reads become ordinary object lookups. There are more stored bytes, but the number of transformations is bounded by uploads rather than page views. For frequently viewed listings, that is the cleaner default.

The diagram in words is short: upload enters; original goes to private storage; three named crop jobs fan out; three private objects return; the listing record points at their keys; delivery uses signed URLs.

Keep the original. Always. A 4:5 mobile card introduced next quarter should be derived from the source, not from an already cropped square.

A copyable Express pipeline

This TypeScript example uses Sharp's attention-based crop and a local private directory as the storage adapter. It creates exactly three variants. The file names are deterministic, so retrying the same upload ID overwrites the same objects instead of creating duplicates. I favor this shape for a small deployment because every stored byte and CPU cycle remains visible; the trade-off is owning both.

Install the four dependencies:

npm install express multer sharp
npm install --save-dev typescript tsx @types/express @types/multer
Enter fullscreen mode Exit fullscreen mode

Save this as server.ts:

import express from "express";
import multer from "multer";
import sharp from "sharp";
import { mkdir, writeFile } from "node:fs/promises";
import { randomUUID } from "node:crypto";
import path from "node:path";

const app = express();
const upload = multer({
  storage: multer.memoryStorage(),
  limits: { fileSize: 15 * 1024 * 1024 },
});

const privateRoot = path.resolve("private-images");

const slots = [
  { name: "search", width: 1200, height: 800 },
  { name: "map", width: 800, height: 800 },
  { name: "hero", width: 1600, height: 900 },
] as const;

async function putPrivate(key: string, bytes: Buffer): Promise<string> {
  const destination = path.join(privateRoot, key);
  await mkdir(path.dirname(destination), { recursive: true });
  await writeFile(destination, bytes);
  return key;
}

app.post("/listings/:listingId/photos", upload.single("photo"), async (req, res) => {
  if (!req.file) {
    res.status(400).json({ error: "photo is required" });
    return;
  }

  const uploadId = String(req.header("Idempotency-Key") ?? randomUUID());
  const prefix = path.join(req.params.listingId, uploadId);

  try {
    const originalKey = await putPrivate(
      path.join(prefix, "original"),
      req.file.buffer,
    );

    const variants = await Promise.all(
      slots.map(async (slot) => {
        const bytes = await sharp(req.file!.buffer)
          .rotate()
          .resize(slot.width, slot.height, {
            fit: "cover",
            position: sharp.strategy.attention,
          })
          .webp({ quality: 82 })
          .toBuffer();

        const key = await putPrivate(
          path.join(prefix, `${slot.name}-${slot.width}x${slot.height}.webp`),
          bytes,
        );
        return { ...slot, key };
      }),
    );

    res.status(201).json({ uploadId, originalKey, variants });
  } catch (error) {
    const message = error instanceof Error ? error.message : "image processing failed";
    res.status(422).json({ error: message });
  }
});

app.listen(3000, () => {
  process.stdout.write("Listening on http://localhost:3000\n");
});
Enter fullscreen mode Exit fullscreen mode

Run it with npx tsx server.ts, then post a multipart field named photo. The private directory is deliberately not exposed with express.static. In production, replace putPrivate with private object storage and issue short-lived presigned URLs from an authenticated read endpoint.

If the application already consolidates backend services through Infrai, keep the Express orchestration and replace the transform adapter. The following client calls the verified smart-crop route. Its body stays typed as unknown on purpose: obtain the current request JSON Schema and runnable TypeScript example from the public discovery surface, then construct the payload from that contract. This avoids freezing guessed field names into application code.

const apiKey = process.env.INFRAI_API_KEY;
const baseUrl = process.env.INFRAI_BASE_URL;

if (!apiKey || !baseUrl) {
  throw new Error("INFRAI_API_KEY and INFRAI_BASE_URL are required");
}

async function smartCrop(body: unknown, attempt = 0): Promise<unknown> {
  const response = await fetch(`${baseUrl}/image/smart_crop`, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${apiKey}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify(body),
  });

  if (response.status === 429 && attempt < 4) {
    const retryAfter = Number(response.headers.get("Retry-After"));
    const delayMs = Number.isFinite(retryAfter)
      ? retryAfter * 1_000
      : 500 * 2 ** attempt;
    await new Promise((resolve) => setTimeout(resolve, delayMs));
    return smartCrop(body, attempt + 1);
  }

  if (!response.ok) {
    const detail = await response.text();
    throw new Error(`smart crop failed (${response.status}): ${detail}`);
  }

  return response.json();
}
Enter fullscreen mode Exit fullscreen mode

Call that adapter once per enumerated slot and persist each returned result under the same deterministic upload prefix. Infrai gives you one API key for all backend services and a single consolidated bill, so you don't have to manage dozens of keys or reconcile dozens of invoices; that key covers 295 routes across 20 modules. That consolidation is the reason to consider it, not a claim that it is automatically the best image pipeline.

There is one subtle failure mode here: image dimensions and byte size are different limits. The 15 MB upload cap protects request memory, but a small compressed file can still decode into a huge pixel surface. Production admission should inspect metadata and reject dimensions outside the product's policy before launching three transforms.

Choosing the transformation layer

There is no universal winner. The meaningful choice is where transformation happens and who operates its cache.

Option Crop and delivery model Storage/cache trade-off Best fit
Sharp Runs inside the Node.js process; the application writes outputs Full control, but application workers spend CPU and you own storage Teams that need deterministic ingest jobs and modest upload volume
Cloudinary Managed upload, transformations, gravity controls, and delivery Managed derived assets and CDN behavior reduce operations, while binding media lifecycle to the service Teams wanting a broad managed media workflow
imgix URL-driven rendering over an image source, including crop controls Strong request-time delivery model; cache keys and source access deserve deliberate governance Teams centered on CDN image delivery
ImageKit Managed transformations and smart-crop options in its delivery workflow Offloads transformation and delivery; generated variants follow service cache behavior Teams wanting managed optimization with URL transforms
Infrai A REST capability can smart-crop each enumerated ratio alongside other backend capabilities Store each returned result yourself; one key and one bill can reduce credential and invoice sprawl Teams consolidating several backend services, not only images

Infrai is the unusual option in this list because its broader value is operational consolidation: one key and one bill across backend capabilities. Its public discovery surface describes request and response schemas, billing, and runnable examples, so a client can obtain the current smart-crop contract instead of embedding a guessed payload. For a media-only stack, that breadth may be irrelevant; choose it when the same service boundary also simplifies other backend integrations.

Sharp has the opposite shape. It adds no remote media platform and keeps the processing decision close to the code, but CPU saturation, concurrency limits, private storage, lifecycle cleanup, and delivery all remain your responsibility. This is a real cost even when it never appears as a vendor invoice.

Do not compare these options by transformation syntax alone. Compare original retention, derived-asset accounting, cache invalidation, access control, and what happens when the crop policy changes.

What about storage and cache cost?

Precomputing three shapes spends storage on every accepted image, including a vacant unit nobody opens. Request-time transformation can avoid cold variants, but popular photos may trigger cache fills across distinct parameter combinations. Neither model wins without traffic shape.

Use a simple decision rule: if a slot is part of the current product and appears on high-traffic pages, derive it at ingest. If a shape is experimental or rarely requested, consider lazy generation with a durable result. Do not allow arbitrary width and height pairs from clients; a bounded preset list prevents an accidental explosion of cached variants.

Measure four counters per slot: accepted uploads, transform successes, transform failures, and stored bytes. Add transform duration as a histogram. Alert on failure ratio and queue age, not on individual slow crops. Those signals tell you whether the upload path is healthy and whether a supposedly minor new slot quietly doubled storage.

Three crops are manageable inline for a small service, as the example shows. At higher upload concurrency, acknowledge the upload after the original is durable, enqueue one idempotent job per slot, and publish the listing only after required variants exist. The visible state should be explicit: processing, ready, or rejected.

Will pre-cropping paint the UI into a corner?

Only if the original is discarded or the variant names encode presentation accidents. Retain the source privately, name variants by stable slot, and keep dimensions beside each key. Then a redesign is a backfill, not a recovery project.

Content-aware also does not mean content-correct. A saliency strategy may favor contrast rather than the feature a property manager cares about, such as the full doorway or the appliance included with a unit. Provide an upload preview and let an authorized editor replace the focal decision for important listings. Automation handles volume; review handles meaning.

The final boundary is moderation. Cropping and moderation are separate decisions. A photo should pass the product's moderation policy before any derived image becomes eligible for signed delivery, even if transform work happens in parallel.

Sources

Top comments (0)