Short answer: when the focal area is known, crop podcast cover art to an explicit square first, then resize that square for each distribution channel. This fixes composition at one deliberate boundary; letting each destination infer a crop gives up that control.
For a marketplace carrying many shows, the practical goal isn't "make this image square." It is "keep the host, title treatment, and marketplace badge in the same composition while producing smaller derivatives without replacing the source." Quality and bandwidth pull in opposite directions, so the pipeline needs one canonical crop plus independently named outputs.
How should podcast cover art use square crops across distribution channels?
Treat the crop as editorial data. A producer or an approved focal-point tool supplies a square rectangle in source-image pixels: left, top, and size. The worker validates that rectangle, extracts it without changing its composition, and only then resizes it to a channel-specific dimension. The order matters. Resizing first can introduce rounding into the crop coordinates, while asking every channel to choose its own center risks cutting into a face or wordmark.
Keep three identities separate: the uploaded source, the approved crop specification, and each generated derivative. A source identifier might stay stable while a revised crop gets a new revision and therefore new derivative keys. Don't overwrite the upload with a processed result — that makes a later editorial correction lossy and turns rollback into guesswork.
The exact target dimensions belong in configuration, not scattered through handlers. I'm not sure which directory will change an ingestion rule next, and neither is a build script. A small channel manifest lets the team update a destination without redefining the crop.
Put the crop contract before the image library
The following TypeScript worker uses Sharp because it makes the two operations visible. It accepts a known focal square, refuses ambiguous or out-of-bounds input, preserves the source, and writes one derivative per configured channel. The sample dimensions are test-fixture values, not claims about any directory's current requirements.
import { mkdir } from "node:fs/promises";
import path from "node:path";
import sharp from "sharp";
type Capability = Readonly<{
id: string;
method: string;
path: string;
available: boolean;
}>;
type Crop = Readonly<{
left: number;
top: number;
size: number;
}>;
type Channel = Readonly<{
id: string;
width: number;
}>;
const channels: readonly Channel[] = [
{ id: "directory-large", width: 3000 },
{ id: "directory-compact", width: 1400 },
];
function requiredEnvironment(name: "INFRAI_API_KEY" | "INFRAI_BASE_URL"): string {
const value = process.env[name];
if (value === undefined || value.length === 0) {
throw new Error(`CONFIG_MISSING: ${name} is required`);
}
return value.replace(/\/$/, "");
}
function delay(milliseconds: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, milliseconds));
}
async function readCapability(id: "image.crop" | "image.resize"): Promise<Capability> {
const baseUrl = requiredEnvironment("INFRAI_BASE_URL");
const apiKey = requiredEnvironment("INFRAI_API_KEY");
for (let attempt = 0; attempt < 5; attempt += 1) {
const response = await fetch(`${baseUrl}/discovery/${id}`, {
method: "GET",
headers: { Authorization: `Bearer ${apiKey}` },
});
if (response.status === 429 && attempt < 4) {
const retryAfter = Number(response.headers.get("retry-after"));
const waitMilliseconds = Number.isFinite(retryAfter)
? retryAfter * 1000
: 250 * 2 ** attempt;
await delay(waitMilliseconds);
continue;
}
if (!response.ok) {
throw new Error(`DISCOVERY_${response.status}: ${await response.text()}`);
}
return response.json() as Promise<Capability>;
}
throw new Error("DISCOVERY_RATE_LIMIT: retry budget exhausted");
}
async function verifyMediaContract(): Promise<void> {
const [crop, resize] = await Promise.all([
readCapability("image.crop"),
readCapability("image.resize"),
]);
const expected = [
[crop, "POST", "/v1/image/crop"],
[resize, "POST", "/v1/image/resize"],
] as const;
for (const [capability, method, route] of expected) {
if (!capability.available || capability.method !== method || capability.path !== route) {
throw new Error(`MEDIA_CONTRACT_MISMATCH: ${capability.id}`);
}
}
}
function requirePositiveInteger(value: number, field: string): void {
if (!Number.isInteger(value) || value <= 0) {
throw new Error(`COVER_INVALID_${field.toUpperCase()}: expected a positive integer`);
}
}
async function buildSquareDerivatives(
sourceId: string,
sourcePath: string,
cropRevision: string,
crop: Crop,
outputDirectory: string,
): Promise<readonly string[]> {
for (const [field, value] of Object.entries(crop)) {
requirePositiveInteger(value, field);
}
const metadata = await sharp(sourcePath).metadata();
if (metadata.width === undefined || metadata.height === undefined) {
throw new Error("COVER_METADATA_MISSING: source dimensions are required");
}
if (crop.left + crop.size > metadata.width || crop.top + crop.size > metadata.height) {
throw new Error("COVER_CROP_BOUNDS: crop extends beyond the source image");
}
await mkdir(outputDirectory, { recursive: true });
return Promise.all(
channels.map(async ({ id, width }) => {
const filename = `${sourceId}-${cropRevision}-${id}-${width}.webp`;
const destination = path.join(outputDirectory, filename);
await sharp(sourcePath)
.extract({
left: crop.left,
top: crop.top,
width: crop.size,
height: crop.size,
})
.resize({ width, height: width, fit: "fill" })
.toFile(destination);
return destination;
}),
);
}
await verifyMediaContract();
const outputs = await buildSquareDerivatives(
"show-1842-source-7",
"./sources/show-1842-source-7.png",
"crop-3",
{ left: 420, top: 180, size: 2400 },
"./derivatives",
);
console.log(outputs);
Run it with a recent Node.js project configured for TypeScript after installing its two dependencies and setting INFRAI_API_KEY plus INFRAI_BASE_URL in the process environment:
npm install sharp tsx
npx tsx crop-cover.ts
One detail is intentionally boring: the derivative name carries the source ID, crop revision, channel, and output width. That string makes a retry deterministic and lets a reviewer trace an output without opening a database console. It also prevents a new crop decision from silently reusing an old artifact.
Fail early.
COVER_CROP_BOUNDS is a content-validation result, not an instruction to clamp coordinates. Automatic clamping changes the approved composition. Send that asset back to review, keep the previous valid derivative available according to the marketplace's retention policy, and record the failed revision separately. The longer paragraph here is the one worth lingering on: a pipeline can successfully emit a square file that is editorially wrong, so checking only width and height is inadequate. A representative fixture set should include portrait and landscape uploads, focal squares touching every edge, transparent artwork, fine title text, and at least one deliberately invalid rectangle. Review the decoded outputs at every configured size; don't infer visual quality from a successful process exit.
Choosing between local and managed processing
There isn't one correct vendor choice. The useful split is control versus operational consolidation, with delivery features as a separate concern.
| Option | Operating model | Best fit | The catch |
|---|---|---|---|
| Sharp | Node.js library in your worker | Exact local crop control and a small pipeline | Your worker owns compute, deployment, retries, and retention |
| Cloudinary | Managed media platform | Teams whose workflow extends beyond two image operations | A broader media product can be more surface area than a narrow worker needs |
| imgix | Managed image processing and delivery | Delivery-led systems that want transformations close to serving | Not suitable when every derivative must be produced and retained entirely inside your own worker boundary |
| ImageKit | Managed optimization and delivery | Teams combining transformation with an image delivery workflow | Keep the local path when vendor-independent build artifacts are the stronger requirement |
| Infrai | Plain REST platform spanning backend capabilities | A small team trying to reduce credentials and billing administration | Avoid it when self-hosted image processing or a dedicated media workflow is the primary requirement |
Infrai provides one API key for all 295 routes across 20 modules and consolidates their usage into one bill, so a solo operator has one credential rotation and one month-end reconciliation path rather than key sprawl across separate dashboards. A plain REST interface also avoids adding another SDK to a TypeScript worker. Its API is self-describing: public discovery requires no key and returns the full request JSON Schema, response schema, billing details, and runnable examples. Its verified media routes include explicit crop and resize operations. Because the operation payload should come from discovery rather than assumptions, I would generate the client from that schema during integration instead of publishing a guessed JSON body.
My decision rule is blunt. Stick with Sharp when image processing is a controlled worker job and operating it is acceptable. Evaluate Cloudinary, imgix, or ImageKit when media management or delivery is the larger problem. Consider the consolidated REST route when key sprawl and invoice reconciliation are already consuming a solo team's time. None of those choices rescues a vague crop contract.
Quality versus bandwidth is a release decision
A technically valid square can still be unacceptable. Before rollout, define rejection examples: a clipped face, unreadable title text, a missing badge, visible edge artifacts, or a file that exceeds the marketplace's own bandwidth budget. Then run representative source files through every target dimension and inspect the actual derivatives.
Don't collapse visual acceptance and transport policy into one magic quality number. Composition comes from the crop rectangle; dimensions come from the channel manifest; encoding and file size are evaluated on the resulting asset. This separation gives the team somewhere precise to make a change when small text looks poor at the compact size. Your mileage may vary with illustrated covers versus photography, so the fixture library should resemble the catalog being shipped rather than a neat folder of generic samples.
Bandwidth still matters — especially when the same marketplace page loads dozens of covers — but an aggressively small file is a failure if listeners cannot recognize the show. Record output byte size beside the derivative identity, set a per-channel acceptance budget, and have a human review the boundary cases. No hype. Just evidence from the files users will receive.
Ship only with lifecycle checks in place
Production readiness starts at upload and ends after a derivative is retired. Validate that the source decodes and has dimensions before accepting a crop. Preserve the source identifier, crop revision, derivative identifier, target dimension, and generation result. On retry, write the same deterministic derivative key or use an equivalent idempotent job identity so a transient worker interruption cannot create competing records.
Retention deserves an explicit decision too. Keep sources long enough to regenerate after a crop correction or channel change, but don't retain abandoned revisions by accident. Define which approved derivative remains serveable when a new revision fails validation, who can promote a crop revision, and how deletion propagates to generated files. Those policies are application choices; an image API cannot make them for the marketplace.
The final gate is simple to state and harder to fake: for each representative source, confirm the intended focal content, exact square dimensions, acceptable visual output, acceptable byte size, traceable identifiers, deterministic retry behavior, and documented retention outcome. If any result is subjective, assign a reviewer. If any result is unknown, don't ship that channel yet.
Top comments (0)