All Cloudinary calls in unshutter live here (see AGENTS.md). This directory
depends on two named transformations that must exist in the Cloudinary
console before Preview/OG URLs will resolve — they are not created by code.
-
Strict Transformations: ON for the account/environment used by this app (Settings → Security). Without this, derived versions of
type: privateassets are publicly addressable without going through an allowlisted named transformation — the watermark would be bypassable. -
Named transformation
preview(renders ast_previewin URLs) —allowed_for_strict: true. Must contain:- a watermark overlay (
l_<watermark-overlay-public-id>+ positioning) q_auto:low(aggressive compression — the degradation is the product)c_limit,w_1200(size cap)
- a watermark overlay (
-
Named transformation
og(renders ast_ogin URLs) —allowed_for_strict: true. Must contain:w_1200,h_630,c_auto,g_auto(smart crop to exact OG dimensions)- a generative-fill fallback for source images whose aspect ratio makes
a straight crop ugly (e.g.
b_gen_fillor anif-conditional variant)
-
Allowlist the chained
f_autovariant of each, sincef_autocannot live inside a named transformation itself (confirmed indocs/VERIFIED.md):t_preview/f_autot_og/f_auto
Either add both chained forms to the strict-transformation allowlist directly, or eager-generate them on upload so Cloudinary already has a cached derived asset matching the exact requested transformation string.
buildPreviewUrl.ts/buildOgUrl.tsalways request exactlyimage/private/t_preview/f_auto/<public_id>andimage/private/t_og/f_auto/<public_id>respectively — the allowlist entry (or eager transformation) must match that exact chained string. -
Videos are
type: authenticated, notprivate. The image model (private original + strict-mode-allowlisted derived transformations) did not prove a reliable fit for video delivery in this environment when tested 2026-07-09 — treat that as a local observation, not confirmed Cloudinary behavior.authenticatedremoves the ambiguity entirely: the original AND every derived asset require a URL signature regardless of strict-mode configuration, which the video URL builders add viasign_url(signatures are deterministic, so the URLs are stable and cacheable). The watermark stays un-bypassable because the only signed URLs the app ever emits are the watermarkedt_video_preview/t_video_stillrenditions and the short-lived signed Download. SeeoriginalDeliveryTypeinuploadOriginal.ts. -
Named transformation
video_preview(renders ast_video_preview) — the 5-second watermarked Preview of a video Original. Created 2026-07-09 via the Admin API with, in this order:e_preview:duration_5(AI-selected 5s excerpt — must come first so the watermark/scale apply to the excerpt, not the full video)c_limit,w_640(low-res — the degradation is the product)- the same text watermark overlay as
preview(l_text:Arial_60_bold:%C2%A9%20unshutter, white,o_30, relativew_0.5, centered) q_auto:low
e_previewcannot be generated on the fly (a dynamic request returns HTTP 423 until the derived asset exists), souploadOriginal.tseager-generatest_video_preview(as mp4) at ingestion. -
Named transformation
video_still(renders ast_video_still) — applied to each of the six extracted filmstrip frames. Created 2026-07-09 via the Admin API with:c_limit,w_800- the same watermark overlay as above
q_auto:low
Delivered chained after a start offset:
video/authenticated/s--<sig>--/so_<N>p/t_video_still/<public_id>.jpgwith offsets 14/29/43/57/71/86 (seevideoTransformations.ts). All six chains are eager-generated at upload so no viewer waits on frame extraction; the eager strings and the URL builders share their raw transformation strings, keeping them byte-identical. -
Webhook: video eager generation runs with
eager_async(video analysis takes seconds–minutes) and notifies<NEXT_PUBLIC_SERVER_URL>/api/cloudinary/notify, which verifies theX-Cld-Signatureheader and flips the doc'spreviewReady. The storefront hides a video until then. Cloudinary can't POST to localhost — in local dev, tickpreviewReadymanually in the admin once the derived assets exist.
Strict Transformations allowlists a transformation string (t_preview,
t_og, t_preview/f_auto, …) for every private asset in the account —
it is NOT scoped per asset or per folder. Consequence for photosets: a
watermarked, low-res (q_auto:low, 1200px-capped) rendition of every
set member is publicly derivable via t_preview/f_auto by whoever can name
its public_id — the isPreview flag only controls what the storefront
links, not what Cloudinary will serve. Member public_ids are guessable
(unshutter/sets/<setId>/<filename-slug>), so this effectively exposes a
watermarked contact sheet of a whole set.
This is an accepted trade-off (2026-07-09): only the full-resolution,
un-watermarked Original is truly gated (raw + arbitrary transforms return
401; delivered only via the signed post-payment zip). If hiding non-preview
members ever becomes a requirement, the fix is to upload set members as
type: authenticated (like videos) and sign only the curated preview URLs —
then unsigned t_preview requests 401 and enumeration yields nothing.
| File | Responsibility |
|---|---|
config.ts |
Configures the cloudinary v2 SDK singleton from src/lib/env.ts (supports CLOUDINARY_URL or discrete vars), secure: true. Every other file here imports its cloudinary export — nothing else calls cloudinary.config. |
publicIds.ts |
Single source of truth for public_id conventions: unshutter/originals/<slug> for Photos, unshutter/sets/<photosetId>/<filename-slug> for photoset members (filename in the id = human-readable zip entries). Pure, no SDK. |
uploadOriginal.ts |
Uploads a buffer via upload_stream with a deterministic public_id and overwrite: true. Images: type: private + AI options (categorization: 'google_tagging', auto_tagging: 0.6, detection: 'captioning'). Videos (resourceType: 'video'): type: authenticated (see console setup §5) + the eager derived assets from videoTransformations.ts with eager_async + eager_notification_url (no AI — the built-in video tagger is dialogue-only, so video tags are manual). Also sets asset_folder + display_name (see dynamic folders note below). Defensively parses tags + caption + duration from the response. Exports originalDeliveryType and destroyOriginal (both resource-type aware). |
videoTransformations.ts |
Pure single source of truth for video derived-asset transformation strings: t_video_preview (mp4) and the six so_<N>p/t_video_still chains (jpg). Shared by the eager array and the URL builders so both stay byte-identical. |
buildPreviewUrl.ts |
Public unsigned URL string for the watermarked Preview: image/private/t_preview/f_auto/<public_id>. Used by non-JSX consumers (Stripe line-item thumbnails). JSX renders Previews via src/components/PreviewImage.tsx instead — a next-cloudinary CldImage whose strictTransformations + namedTransformations: ['preview/f_auto'] props emit this same allowlisted chain. |
buildVideoPreviewUrl.ts |
Signed URL for a video's 5s watermarked Preview: video/authenticated/s--<sig>--/t_video_preview/<public_id>.mp4 (eager-generated at upload — see console setup above). |
buildVideoStillUrls.ts |
Signed URLs for the six watermarked Stills: video/authenticated/s--<sig>--/so_<N>p/t_video_still/<public_id>.jpg. buildVideoPosterUrl (the first Still) doubles as gallery poster, Stripe thumbnail, and OG image for videos. |
buildOgUrl.ts |
Public unsigned URL for the 1200x630 OG image: image/private/t_og/f_auto/<public_id>. Images only — videos use buildVideoPosterUrl. |
buildDownloadUrl.ts |
Signed, ~10-minute Download URL for the Original via cloudinary.utils.private_download_url. Takes resourceType — the download endpoint is per-resource-type, and the delivery type follows originalDeliveryType. |
buildPhotosetZipUrl.ts |
Signed, ~10-minute URL streaming a zip of a photoset's member Originals via cloudinary.utils.download_zip_url (generate_archive mode=download; verified live against type: private assets). Zip entry names come from the public_ids — which is why member public_ids encode the source filename (setImagePublicId). No console setup required. |
verifyNotificationSignature.ts |
SDK wrapper verifying Cloudinary webhook signatures for /api/cloudinary/notify. |
This account uses dynamic folders (asset_folder decoupled from
public_id). Verified empirically: uploading with a slash-containing
public_id (e.g. unshutter/originals/<slug>) and no explicit
asset_folder lands the asset with an empty asset_folder — it shows as
uncategorized in the Media Library even though the public_id still encodes
the intended path, and delivery URLs are unaffected either way (they're keyed
off public_id, not asset_folder). uploadOriginal.ts sets asset_folder: ORIGINALS_FOLDER and display_name: <slug> explicitly so Originals are
organized under unshutter/originals in the Media Library. publicIds.ts
remains the single source of truth for the deterministic, slash-containing
public_id — this is purely an admin-UI organization improvement with zero
effect on URL semantics.
vitest.setup.ts sets a fake CLOUDINARY_URL (cloud_name test-cloud)
before any test file loads, so every test in this directory — including ones
that import config.ts transitively — runs deterministically without a real
Cloudinary account. config.test.ts overrides this further per-test (via
vi.resetModules()) to exercise both the CLOUDINARY_URL and discrete-vars
branches. uploadOriginal.test.ts mocks the cloudinary uploader entirely —
no network calls are made by pnpm test:int.