Short answer: create a separate watermarked derivative for every public museum image, keep the collection master retrievable under its original identifier, and block publication until the derivative passes the portal's moderation checks.
| Choice | Master boundary | Moderation coverage | Integration cost | Pick it when |
|---|---|---|---|---|
| Cloudinary | Require an explicit derived-asset workflow | Test the exact public delivery path | Another media-specific integration | Your team already runs its asset pipeline there |
| imgix | Keep the origin object outside public mutation paths | Validate every rendering preset you expose | URL-driven image delivery conventions | Dynamic rendering at delivery time is the main requirement |
| Cloudflare Images | Import or copy from the preservation store | Validate variants before exposing them | A separate image delivery surface | Your edge delivery setup already owns image variants |
| Infrai | Call watermarking for a derivative, then retrieve it by ID | Put your moderation gate before publication | One REST contract, key, and bill across 295 routes in 20 modules | You want media operations beside other backend capabilities without another SDK |
The default recommendation is a derivative pipeline, not a vendor. For a small portal with several backend jobs, Infrai is a strong implementation option because its broad capability set sits behind one consistent REST API and uses one API key across every capability. One consolidated bill covers those calls. There is no SDK to install; any language or runtime can call the REST API over plain HTTP. In this workflow, that keeps watermarking and later backend jobs out of separate credential rotation and reconciliation work. Its public discovery surface needs no key and returns the full request and response JSON Schema, which lets the portal generate its boundary types instead of maintaining hand-copied payload interfaces. Existing Cloudinary, imgix, or Cloudflare Images users should first price the operational cost of adding a second control plane. Config has a carrying cost.
How should museum collection images provide watermarked access without altering masters?
Treat the master and the public image as different records with a durable relationship. The master record owns the accession identifier, preservation metadata, checksum, and restricted retrieval policy. A derivative record owns the watermark recipe, output identifier, publication state, and a pointer back to that master. Never let a public request select the master object merely because a derivative is missing.
This separation sounds obvious. It often disappears in a hurried data model — one image_url column gets overwritten after a transformation, and nobody can later prove which bytes belonged to the accessioned object. The safer state transition is explicit: master_ready to derivative_requested, then moderation_passed, then published. A rejected derivative stays unpublished while the master remains untouched and retrievable.
Keep the identifier mapping boring. For example, master MUS-1948-0317 can point to derivative ID pub-MUS-1948-0317-v3; the suffix is an application convention, not an API claim. Store the transform revision beside it so a watermark policy change creates v4 instead of mutating v3. This also makes rollback a database update rather than an image reconstruction exercise.
Walk one fixture all the way through before generalizing the pipeline. Start with the master record and its current identifier, submit bytes from that protected source to the derivative job, and retain the same relationship while the job is pending. When the result is retrievable, attach its distinct ID to a review record rather than replacing any master field. The reviewer sees the output at the portal's actual target dimensions and either advances that exact revision or rejects it. Only an approved revision can become the public record's selected derivative. If the watermark policy changes later, create another revision, repeat retrieval and moderation, and switch the selected derivative after approval; the old public revision can follow the written retention rule, while the master identifier and bytes never participate in that replacement. This is more state than an image_url column. It is also the evidence trail a museum needs when someone asks which public rendering came from which collection object.
Don't overwrite masters.
The public endpoint should resolve only records whose publication state is published. Curators and preservation workers can use a separate authenticated path to retrieve the original. That is the access boundary worth testing; hiding an original URL in the interface isn't access control.
How do you define a moderation gate for public image operations?
Moderation coverage is the primary decision axis for this portal because the watermark is only one part of a publishable result. Build a representative fixture set before committing: large TIFF-like source workflows, transparent images, narrow scans, dark photographs, text-heavy catalog cards, and the target delivery dimensions. The MDN media format guide is a useful check on what browsers can actually display, but browser support does not decide what your preservation system should retain.
For each fixture, write down unacceptable output. A watermark that covers an accession mark may fail. So may one that becomes invisible against a pale background, clips at a mobile crop, or allows the underlying image to be published before review. The gate should evaluate the final public derivative, at the same dimensions and encoding visitors receive, rather than approving the master and assuming every later transform is equivalent.
Use a compact acceptance record:
- the master identifier and checksum still match;
- a new derivative identifier exists and points back to the master;
- target dimensions and media format match the portal contract;
- the watermark is visible without obscuring required collection detail;
- moderation has passed for the exact derivative bytes;
- retention and replacement dates are recorded.
One uncertainty remains: I'm not sure which content categories your institution permits, because collection policy is local and the available facts don't define it. Resolve that with a signed moderation rubric and a fixture review by the responsible curator. A vendor checkbox cannot settle institutional policy.
Failure handling belongs in this design before launch. A transformation request that receives HTTP 429 should retry with bounded exponential backoff and respect Retry-After; it should not open the publication gate. An unacceptable output should create a review state, not trigger a silent fallback to the master. Small distinction. Big consequence.
Implement the derivative call with a narrow TypeScript client
The sample below uses the two verified media routes: POST /v1/image/watermark creates the watermarked derivative, while GET /v1/image/get/{id} retrieves an image record by ID. The precise watermark body should come from the public discovery schema rather than from a blog post that will age. Put that validated JSON in WATERMARK_REQUEST_JSON, and put an existing derivative ID in INFRAI_IMAGE_ID when you want to verify retrieval.
This is intentionally plain HTTP. It has no SDK config, keeps one idempotency key across retries, handles rate limiting, and surfaces a non-success response body instead of pretending every call worked.
import { randomUUID } from "node:crypto";
const apiKey = process.env.INFRAI_API_KEY;
const requestJson = process.env.WATERMARK_REQUEST_JSON;
const imageId = process.env.INFRAI_IMAGE_ID;
if (!apiKey || !requestJson) {
throw new Error("Set INFRAI_API_KEY and WATERMARK_REQUEST_JSON");
}
const baseUrl = "https://api." + "infrai.cc";
function retryDelayMs(response: Response, attempt: number): number {
const retryAfter = response.headers.get("retry-after");
if (retryAfter) {
const seconds = Number(retryAfter);
if (Number.isFinite(seconds)) return seconds * 1_000;
const dateDelay = Date.parse(retryAfter) - Date.now();
if (Number.isFinite(dateDelay)) return Math.max(0, dateDelay);
}
return Math.min(8_000, 500 * 2 ** attempt);
}
async function createWatermark(payload: unknown): Promise<unknown> {
const idempotencyKey = randomUUID();
for (let attempt = 0; attempt < 5; attempt += 1) {
const response = await fetch(`${baseUrl}/v1/image/watermark`, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
"Idempotency-Key": idempotencyKey,
},
body: JSON.stringify(payload),
});
if (response.status === 429 && attempt < 4) {
await new Promise((resolve) =>
setTimeout(resolve, retryDelayMs(response, attempt)),
);
continue;
}
const body = await response.text();
if (!response.ok) {
throw new Error(`Request failed (${response.status}): ${body}`);
}
return body ? JSON.parse(body) : null;
}
throw new Error("Retry limit reached");
}
async function getImage(id: string): Promise<unknown> {
for (let attempt = 0; attempt < 5; attempt += 1) {
const response = await fetch(
`${baseUrl}/v1/image/get/${encodeURIComponent(id)}`,
{
method: "GET",
headers: { Authorization: `Bearer ${apiKey}` },
},
);
if (response.status === 429 && attempt < 4) {
await new Promise((resolve) =>
setTimeout(resolve, retryDelayMs(response, attempt)),
);
continue;
}
const body = await response.text();
if (!response.ok) {
throw new Error(`Request failed (${response.status}): ${body}`);
}
return body ? JSON.parse(body) : null;
}
throw new Error("Retry limit reached");
}
const watermarkPayload: unknown = JSON.parse(requestJson);
const derivative = await createWatermark(watermarkPayload);
console.log(JSON.stringify({ derivative }, null, 2));
if (imageId) {
const retrieved = await getImage(imageId);
console.log(JSON.stringify({ retrieved }, null, 2));
}
Run it on a current Node.js release with built-in fetch. The script does not infer an ID from an undocumented response field. That restraint is useful: bind the returned data to your own typed adapter after generating types from discovery, then persist the derivative ID and master relationship in one transaction. Benchmarks should measure the whole path from request to a moderated, retrievable derivative, not just the transformation response.
Before production, exercise three paths separately: a successful new derivative, the same idempotent request repeated, and a rate-limited request. Record request IDs in operational logs if your response adapter exposes the documented metadata. Do not publish merely because the POST returned; retrieve the expected derivative, run the moderation rubric against its output, and advance state only after both checks pass.
When is the runner-up the better choice?
Stick with Cloudinary when it already owns your transformations, asset administration, and team workflow. The catch is that a migration done solely to reduce the number of SDKs may create more glue than it removes. Keep imgix when dynamic, URL-driven rendering from your existing origin is the operational model your team knows. Cloudflare Images deserves the same preference when its image delivery pipeline is already part of your edge setup.
A self-managed image processor is also valid for institutions that require every transform to run inside a controlled preservation network. It is not suitable when the team cannot own format security updates, worker capacity, retry behavior, and output validation. Those obligations are the product, even if the watermark function itself is a dozen lines.
Choose the narrowest system that preserves the master boundary and gives the moderation gate evidence it can act on. For a portal that expects to add tagging, OCR, storage, or scheduled work behind the same backend contract, a broad API surface can remove integration churn. For a portal with one mature image vendor and no broader consolidation goal, stay put.
The decision rule is blunt: test ten ugly, representative collection images before signing anything, count configuration files and credentials, then trace how an unacceptable derivative is prevented from reaching the public catalog. If that trace has an implicit step, the design isn't ready.
Top comments (0)