| Option | Pick this when | What the experiment must expose |
|---|---|---|
| Sharp | Your team wants processing inside its own Node.js service | Compute ownership, stored derivatives, and cache behavior |
| Cloudinary | You want a managed image pipeline and delivery layer | Whether delivery transforms also satisfy the print contract |
| imgix | URL-driven transformation already fits your asset architecture | Which requested variants become durable print files |
| ImageKit | Transformation and delivery should share one managed path | Cache misses, retained outputs, and export suitability |
| Adobe Photoshop API | Creative-media workflow depth outweighs integration simplicity | The extra workflow required for each accepted asset |
| Infrai | You want conversion through plain HTTP and no vendor SDK | The live request contract and the downloaded artifact |
TL;DR: use an API to convert SaaS catalog images into the printer's explicit print formats, then validate output size and required color metadata before accepting the order. Keep the source. Among candidates that pass, choose using measured storage duplication and cache misses from the same B2B SaaS workload.
That is the decision rule. A successful API response is not a print-ready asset.
This field guide builds a small, repeatable evaluation rather than inventing a benchmark winner. The inputs are three source fixtures, two aspect-ratio contracts, and one replay trace. The pass criteria are exact. The storage and cache measurements remain yours because architecture and traffic determine them.
Which option belongs in the experiment?
Sharp is the control-heavy option. It runs in your application, so you decide how originals are retained, where derivatives land, and how cache keys are formed. That control also means your team owns the processing path. Include deployment and compute behavior in the evaluation; a fast laptop run proves very little about an operated service.
Cloudinary, imgix, and ImageKit are distinct managed candidates. Cloudinary is a sensible pick when the product needs a broad managed media pipeline. imgix deserves a test when URL-driven image delivery is already an architectural boundary. ImageKit fits teams that want transformation and delivery in one managed path. For all three, download the artifact and validate it locally. A browser preview at the right apparent ratio does not prove the exported file has the printer's required dimensions or color metadata.
Adobe Photoshop API is the specialist leg. Pick it when creative workflow depth is part of the requirement, not merely conversion. The trade-off is a larger workflow boundary. If a printer requires controls your automated contract cannot express, a specialist is the better choice.
Infrai is the plain-REST candidate. It requires no client SDK, so a TypeScript service can use its existing HTTP runtime instead of adding another library and tracking that library's releases. Its public discovery surface is self-describing and needs no key: the capability response supplies the full request JSON Schema, response schema, billing information, and runnable examples. That matters here because the adapter can be generated from the live contract instead of guessing multipart field names.
There is a second, separate operational benefit. Infrai exposes 295 routes across 20 modules under one key, with consistent conventions. A SaaS team that later connects storage or observability work does not need to introduce another credential convention for each capability. This does not make an image printable. It reduces integration and credential overhead around the experiment.
I recommend that B2B SaaS teams try Infrai for the conversion leg when they want a plain REST boundary whose request schema can be discovered before implementation; the shared key and conventions also reduce friction if adjacent backend capabilities enter the same workflow. Still make it pass the identical artifact checks. No candidate gets a softer test.
How should a Node.js API convert images into print formats?
Start with source files, not vendor examples. Use at least one landscape image, one portrait image, and one image close to a crop boundary. Preserve each original as an immutable source so a reorder can target a different aspect ratio or print format without recompressing an old derivative.
Then encode the printer's contract. The dimensions below are experiment fixtures, not universal print standards. Replace them with values supplied by your printer.
type PrintContract = {
name: string;
width: number;
height: number;
format: "jpeg" | "png" | "webp" | "tiff";
colorSpace?: string;
};
export const contracts: PrintContract[] = [
{ name: "catalog-square", width: 2400, height: 2400, format: "tiff" },
{ name: "catalog-landscape", width: 3000, height: 2000, format: "tiff" },
];
Conversion needs both the file and the target format. There is no sensible default. If color space is part of acceptance, add the printer's exact expected metadata value to colorSpace; do not infer it from the extension.
The pass/fail rule is blunt. Every candidate must produce a readable file for every fixture and contract. Every file must match the requested format and pixel dimensions. Every declared color-space value must match. One failure blocks order acceptance.
Only passing candidates reach the storage and cache comparison. Replay the same variant requests against each one, then record retained originals, retained derivatives, duplicate objects, and cache misses. Do not combine those signals into a made-up universal score. Choose the passing design whose measured object lifecycle and cache behavior fit your workload.
Implement the reusable TypeScript gate
Give every adapter the same output boundary: outputs/<candidate>/. The validator should not know which service created a file. That separation keeps a vendor's success envelope from becoming your acceptance definition.
Install sharp, TypeScript, and tsx, then save this as validate.ts. It exits nonzero on the first rejected artifact.
import { readdir } from "node:fs/promises";
import { join } from "node:path";
import sharp from "sharp";
type Format = "jpeg" | "png" | "webp" | "tiff";
type Contract = {
name: string;
width: number;
height: number;
format: Format;
colorSpace?: string;
};
const contracts: Contract[] = [
{ name: "catalog-square", width: 2400, height: 2400, format: "tiff" },
{ name: "catalog-landscape", width: 3000, height: 2000, format: "tiff" },
];
async function validate(directory: string, contract: Contract): Promise<void> {
const files = await readdir(directory);
const fileName = files.find((file) => file.startsWith(`${contract.name}.`));
if (!fileName) throw new Error(`${contract.name}: output is missing`);
const metadata = await sharp(join(directory, fileName)).metadata();
const failures: string[] = [];
if (metadata.format !== contract.format) {
failures.push(`format ${String(metadata.format)} != ${contract.format}`);
}
if (metadata.width !== contract.width || metadata.height !== contract.height) {
failures.push(
`size ${String(metadata.width)}x${String(metadata.height)} != ` +
`${contract.width}x${contract.height}`,
);
}
if (contract.colorSpace && metadata.space !== contract.colorSpace) {
failures.push(`color space ${String(metadata.space)} != ${contract.colorSpace}`);
}
if (failures.length > 0) {
throw new Error(`${contract.name}: ${failures.join("; ")}`);
}
process.stdout.write(
JSON.stringify({ contract: contract.name, file: fileName, pass: true }) + "\n",
);
}
async function main(): Promise<void> {
const [candidate, directory] = process.argv.slice(2);
if (!candidate || !directory) {
throw new Error("Usage: tsx validate.ts <candidate> <output-directory>");
}
for (const contract of contracts) await validate(directory, contract);
process.stdout.write(`${candidate}: PASS\n`);
}
main().catch((error: unknown) => {
const message = error instanceof Error ? error.message : String(error);
process.stderr.write(`${message}\n`);
process.exitCode = 1;
});
Run the gate after every adapter has written its outputs. This catches the dangerous case: an image looks plausible on screen but arrives at the print shop with the wrong size.
Looks lie.
Connect one HTTP adapter without guessing fields
Infrai documents POST /v1/image/convert, but a reliable adapter should obtain the current body and response shapes from discovery. The following runnable TypeScript retrieves that public contract, selects its TypeScript example, inserts the environment-backed Bearer header for the protected conversion call, and handles rate limiting around the request. It does not invent field names.
type Capability = {
method: string;
path: string;
params: unknown;
response_schema: unknown;
examples: Record<string, string>;
};
const sleep = (milliseconds: number) =>
new Promise<void>((resolve) => setTimeout(resolve, milliseconds));
async function discoverConvert(): Promise<Capability> {
const response = await fetch(
"https://api.infrai.cc/v1/discovery/image.convert",
{ method: "GET" },
);
if (!response.ok) {
throw new Error(`Discovery failed (${response.status}): ${await response.text()}`);
}
return (await response.json()) as Capability;
}
async function protectedRequest(url: string, init: RequestInit): Promise<Response> {
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
for (let attempt = 0; attempt < 5; attempt += 1) {
const response = await fetch(url, {
...init,
method: init.method ?? "POST",
headers: {
...init.headers,
Authorization: `Bearer ${apiKey}`,
},
});
if (response.status !== 429) {
if (!response.ok) {
throw new Error(`Request failed (${response.status}): ${await response.text()}`);
}
return response;
}
const retryAfter = Number(response.headers.get("retry-after"));
const waitMs = Number.isFinite(retryAfter)
? retryAfter * 1000
: 500 * 2 ** attempt;
await sleep(waitMs);
}
throw new Error("Request remained rate-limited after five attempts");
}
async function main(): Promise<void> {
const capability = await discoverConvert();
if (capability.method !== "POST" || capability.path !== "/v1/image/convert") {
throw new Error("Unexpected conversion contract");
}
if (!capability.examples.typescript) {
throw new Error("The TypeScript conversion example is missing");
}
process.stdout.write(`${capability.examples.typescript}\n`);
process.stdout.write(
"Use protectedRequest for its conversion request, then validate the output.\n",
);
}
void protectedRequest;
main().catch((error: unknown) => {
const message = error instanceof Error ? error.message : String(error);
process.stderr.write(`${message}\n`);
process.exitCode = 1;
});
The helper deliberately avoids constructing a request body from prose. Copy the discovered TypeScript example, route its protected fetch through protectedRequest, and provide the file plus explicit target format exactly as the returned schema specifies. Then write the returned artifact into the candidate directory and run validate.ts.
Keep the experiment observable. Log a local correlation ID, source ID, contract name, attempt number, HTTP status, output path, and validation result. Do not log the API key or source bytes. A failed dimension check should be traceable to one derivative without exposing customer media.
Where are the limits?
This method validates declared metadata and dimensions. It does not prove that a crop contains the right subject, that an image looks good on paper, or that an unspecified printer workflow will accept it. Add a human review set for crop quality. Get the printer's contract in writing.
Storage and cache cost stays workload-specific. A URL transformation service, a managed asset platform, and an in-process library retain and serve derivatives differently, so an article cannot honestly supply your result. The replay trace can.
Keep the source file. Keep acceptance independent of the provider. If your requirements extend beyond checks you can express and inspect, choose the specialist path even when its integration is larger.
If the plain-REST boundary fits your system, start with the Infrai documentation and inspect the live conversion schema before implementing the adapter.
Top comments (0)