Skip to content

feat: per-operation refactor + Asset, Transform, and Admin operations#7

Open
eitanp461 wants to merge 21 commits into
masterfrom
feat/image-video-transformations
Open

feat: per-operation refactor + Asset, Transform, and Admin operations#7
eitanp461 wants to merge 21 commits into
masterfrom
feat/image-video-transformations

Conversation

@eitanp461
Copy link
Copy Markdown

Summary

Expands the Cloudinary community node from a single declarative class into a maintainable, multi-resource node with full test coverage. Adds Asset, Transform, and Admin operations on top of the existing Upload flow, and restructures the codebase around per-operation handlers so new operations are additive.

This is a large branch; it groups into four themes below.

1. Architecture refactor

  • Split the monolithic Cloudinary.node.ts into a thin execute() loop plus a per-operation handler map keyed ${resource}:${operation} (operations/index.ts). Adding an operation no longer touches execute().
  • Moved all UI field definitions into descriptions/, driving the node UI through displayOptions.show on resource + operation.
  • Extracted shared helpers into cloudinary.utils.ts: signing, hand-rolled multipart (no form-data dep), URL builders, metadata serialization, error extraction. Zero runtime deps beyond the n8n-workflow peer dep (pure-JS SHA-256 in sha256.utils.ts).

2. Asset & Admin operations

  • Asset resource (asset_id-based): Get Asset, Update Display Name, plus Append-mode tag updates.
  • Delete Assets (bulk by public_id).
  • Admin API: asset Search and Get Tags / Metadata Fields, over HTTP Basic auth.
  • Field/output naming mirrors the Cloudinary API verbatim (type, public_id, resource_type, tags, context) so Search results pipe straight into later operations.

3. Transform resource (delivery-URL construction)

  • 8 image/video operations that build delivery URLs with no API call: Resize, Crop, Optimize (image/video), Convert, Trim Video, Video Thumbnail, Custom Transformation, plus a Multi-Step builder.
  • Per-transform logic lives in pure component builders (transform/shared.ts) shared between the standalone ops and the Multi-Step fixedCollection, so the two surfaces can't drift.
  • buildDeliveryUrl resolves shared / private-CDN / custom-CNAME hosts from optional privateCdn / secureDistribution credentials.
  • Video Player op: emits an embed URL (player.cloudinary.com/embed/) and a self-hosted player_config JSON. Signed delivery (s--SIG--) is intentionally not implemented yet (disclaimed in the UI).

4. Toolchain, CI & docs

  • Test suite on Vitest with mocked IExecuteFunctions asserting the request contract (URL / method / auth / body) — no network calls. Delivery-URL ops assert returned JSON instead.
  • Regenerated package-lock.json against the public npm registry; aligned node descriptor, peer dep range, .nvmrc, and CI.
  • Corrected the codex node identifier to the community-package namespace; surfaced video alongside images in node search.
  • Extensive contributor notes added to CLAUDE.md (three interaction flows, the two-surface field-definition split, backwards-compatibility rules).

Testing

  • npm test — Vitest suite (utils + per-operation handler contracts).
  • npm run lint / npm run build clean.
  • No secrets or internal infrastructure in the tree or git history; local example workflows live under the gitignored examples/.

eitanp461 and others added 14 commits May 27, 2026 16:56
Restructure the Cloudinary node from a single monolithic execute() into a
declarative handler map: one file per operation grouped by resource, with
field definitions split into dedicated description modules. execute() is now
a thin dispatch loop over input items.

Add an Admin "Search Assets" operation that emits one item per matching
asset, auto-injects a resource_type clause from the selected types, supports
automatic pagination ("Return All"), and surfaces rate-limit and invalid
expression errors with actionable messages.

Harden structured-metadata serialization: multi-value fields render as a
bracketed list of quoted strings and delimiter characters are escaped in
every value, matching Cloudinary's documented metadata format. Sanitize the
multipart upload filename so it can't break request framing.

Introduce a Vitest test suite covering the metadata/signature/URL helpers
and the per-operation request contracts, and share common request headers
and auth across handlers.
- vitest.config.ts: update n8n-workflow alias from dist/index.js to
  dist/cjs/index.js. n8n-workflow@2.16 reorganised its build output and
  no longer ships a top-level dist/index.js, so the previous alias
  resolved to nothing and every test file failed at import time with
  "Cannot find package 'n8n-workflow'".
- .nvmrc: bump from v22.11.0 to v24.16.0 (current Active LTS, Krypton).
  Vitest 4 transitively pulls in std-env@4 (ESM-only) and require()s it
  from its CJS config loader; unflagged require(ESM) needs Node >=20.19
  or >=22.12, so v22.11.0 hit ERR_REQUIRE_ESM on `vitest run`. Moving to
  24.16.0 also matches engines.node already declared in package.json.

All 86 tests pass after these changes.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- Cloudinary.node.ts: switch inputs/outputs to the 'main' string
  literals and drop the NodeConnectionType import. Change group from
  the bogus ['Cloudinary'] to ['transform'] so the node lands in the
  right category in the n8n node picker.
- package.json: pin the n8n-workflow peer range to ^2.0.0 instead of
  '*'. The previous wildcard let any host install try to load this
  node against incompatible majors; we already build and test against
  the 2.x API.
- package-lock.json: regenerated to match.
- .github/workflows/release.yml: drive setup-node from .nvmrc instead
  of hardcoding '22', and enable npm cache so releases install faster
  and stay in lockstep with the local toolchain.
- CLAUDE.md: rewrite the backwards-compatibility section to spell out
  the frozen-by-string vs frozen-by-meaning vs free-to-change axes,
  call out option values and displayOptions narrowing as breaking,
  document the typeVersion escape hatch, and separate workflow-JSON
  compatibility from runtime-host compatibility. Minor tightening
  elsewhere.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Step 1 of the asset-crud reorganization. UI-only relabel — `value` strings
(`updateAsset`, `admin`) are frozen-by-string per CLAUDE.md so saved
workflows still resolve. `Search Assets` belongs with the entity it
operates on; moved its operation entry, handler-map key, and field
`displayOptions.show.resource` from `admin` to `updateAsset`. Search is
not yet released, so no compatibility shim is needed.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds the first new operation in the Asset CRUD effort. Identifies the
asset by its immutable asset_id and hits `GET /resources/:asset_id` —
a single-field form instead of the legacy three-field
(resource_type, type, public_id) shape.

Scope notes:
- Deliberately asset_id-only. No `identifyBy` mode selector — for
  new ops, a one-field form is strictly cleaner UX than gating it
  behind a picker most users wouldn't need.
- Existing `updateTags` / `updateMetadata` are not modified; their
  public_id surface stays exactly as it shipped. asset_id support for
  those ops is the job of the upcoming `asset` resource (see plan).

Adds `buildResourceByAssetIdUrl` utility, the `getOptions` collection
(colors, faces, image_metadata, pages, phash, coordinates,
accessibility_analysis, derived_next_cursor), and per-handler +
URL-builder tests.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds an Asset → Delete Assets op backed by the documented public_id
endpoint:

    DELETE /v1_1/{cloud}/resources/{resource_type}/{type}?public_ids=...

Cloudinary's delete endpoint is public_id-based, not asset_id-based —
an earlier draft assumed otherwise and failed at runtime with
"Illegal ids given". The field accepts:

  - a single public_id (no commas needed),
  - a comma-separated list, or
  - an array from an n8n expression (e.g. `{{ $items().map(...) }}`).

Arrays are pre-joined to a CSV before being placed in `qs` because
n8n's default query serializer turns JS arrays into `public_ids[0]=...`
(bracketed indices), which Cloudinary rejects with "public_ids must be
a list of strings or a comma separated string". Joining to CSV
side-steps the serializer entirely.

New helpers in cloudinary.utils.ts:
  - buildResourceDeleteUrl(cloud, resourceType, type)
  - splitCsvIds(csv) — trims and drops blanks

Adds 8 handler tests covering URL/method, single-string, CSV trimming,
array-from-expression, non-image resource routing, options merging,
and empty-input guards, plus unit tests for the new utils.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Splits the existing Update Asset surface into two resources:
- Asset: asset_id-keyed Get/Update/Search/Delete plus tag and metadata
  operations. asset_id is a single global key, so callers do not need
  to juggle (resource_type, type) coordinates.
- Asset (Legacy): kept at the bottom of the dropdown; only the
  public_id-keyed operations that shipped on master are exposed. Saved
  workflows continue to load unchanged.

Update Asset Tags gains a Mode selector with backward-compatible default:
- Set (default): existing behavior, replaces the tag list via the
  resource endpoint with Basic auth.
- Append: new path hitting POST /:resource_type/tags with command=add
  and signed auth, preserving existing tags. Shared appendTags helper
  lives in operations/tagAppend.ts and is wired into both resources'
  updateTags handlers.

The tag-action endpoint scopes lookups by (resource_type, type) and
silently returns public_ids: [] for assets stored under a non-default
type. Append mode now exposes a Type field (labeled to match the asset
object's "type" property) and threads it into the signed body, so
authenticated/private assets resolve correctly.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The codex "node" field declared n8n-nodes-base.cloudinary, the reserved
namespace for n8n's built-in core nodes. This package's real node type is
n8n-nodes-cloudinary.cloudinary (package name + node "name").

n8n's loader binds a .node.json codex to its node by filename adjacency
(Cloudinary.node.json <-> Cloudinary.node.js) and never reads this field, so
the change is cosmetic — but the previous value was misleading, implying this
community node lived in n8n-nodes-base.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The node already uploads and manages videos, but nothing advertised it: a
"video" search in the n8n node panel didn't list Cloudinary, and the docs
links and description only mentioned images.

- Add a codex "alias" array (video, image, media, transform, transcode,
  optimize, upload, CDN, asset, DAM). The node creator's search keys only on
  displayName and codex.alias, so this is what makes Cloudinary match "video"
  (the same mechanism the Google Gemini and MiniMax nodes use).
- Mention images and videos in the node description (tooltip + the AI-agent
  tool schema exposed via usableAsTool).
- Add the video best practices guide to primaryDocumentation.

All UI-only metadata, so no typeVersion bump.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Introduces transform:optimizeImage, resizeImage, cropImage, convertImage,
optimizeVideo, trimVideo, videoThumbnail, customTransformation, and multiStep.
All operations build a Cloudinary delivery URL (no API call); callers chain
n8n's HTTP Request node to materialize bytes.

Delivery host resolution mirrors the SDK: default res.cloudinary.com (shared),
<cloud>-res.cloudinary.com (private CDN), or custom CNAME — driven by new
optional privateCdn / secureDistribution credential fields (backwards-compat;
default off reproduces today's behaviour.

Component builders in operations/transform/shared.ts are shared between
standalone ops and the multi-step chain; both consumers map to the same
builder to prevent drift. Handlers carry 25 new Vitest cases (156 total)
asserting returned JSON rather than a request spy.

Also includes: trailingMediaFormat fix for public_ids that embed their own
extension, codex alias additions (resize/crop/thumbnail), and CLAUDE.md
documentation for the third flow + no-HTTP test pattern.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
EOF
)
…l chaining

Transform resource increment on top of the 8 delivery-URL operations:

- Video Player: new operation that builds a player.cloudinary.com embed URL
  plus a player_config object (autoplay, loop, muted, source types, poster,
  colors, skin, fluid/aspect ratio, font, and advanced toggles). Emits no
  HTTP request. Embed params use camelCase, matching the embed-page docs
  (snake_case is also accepted by the embedder — verified in-browser).
- Multi-Step: crop step now supports crop-by-aspect-ratio
  (cropAspectWidth + aspectRatio) alongside crop-by-dimensions
  (cropWidth/cropHeight), mapping each to the shared crop builder.
- Video Thumbnail: optional base transformation (thumbnailBaseTransformation)
  prepended before the frame selector, so a thumbnail can chain from a
  previous Trim/Multi-Step transform.
- USER_AGENT now reports n8n-nodes-cloudinary/<version> sourced from
  package.json instead of a hardcoded string.
- Bump version 0.0.9 -> 0.1.0; gitignore examples/ (kept local-only) and
  drop the previously committed social-video POC; CLAUDE.md documents the
  two-surface field-definition split for transform fields.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@eitanp461 eitanp461 requested a review from sveta-slepner June 2, 2026 08:18
eitanp461 and others added 7 commits June 2, 2026 11:35
Restructure the always-loaded agent context to cut per-turn token cost.
CLAUDE.md becomes a one-line `@AGENTS.md` import stub (committed plain
text — no symlink, so it survives Windows/CI), and AGENTS.md holds a lean
core: commands, six one-line core rules, and a one-paragraph architecture
map. The deep reference (three flows, transform builders, backwards-compat
taxonomy, conventions) moves verbatim into docs/, linked with plain
markdown so it loads only when a task touches that area.

AGENTS.md is the cross-agent standard, read directly by Codex/Cursor/etc.;
the CLAUDE.md stub keeps Claude Code working with no duplication.

Always-loaded footprint: ~109 lines -> ~43.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
CI lint failed on node-param-description-miscased-json at the new
thumbnailBaseTransformation field. The rule flags the word "json" in
descriptions and wants "JSON" — but here it is the n8n expression variable
$json, which is correctly lowercase. The autofix (and an earlier manual
edit) rewrote it to $JSON, which is undefined in n8n and would hand users
a broken chaining example.

Keep $json and suppress the false positive on that single line with a
documented eslint-disable, so the guidance stays runtime-correct.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Address PR review findings on the image/video Transform feature:

- fetch/social delivery URLs no longer get a spurious file extension.
  The format suffix (explicit Convert/Thumbnail format or one recovered
  from a dotted public_id) is now appended only for stored-asset types
  (upload/private/authenticated), whose public_id is opaque. For fetch
  and social sources the identifier is a remote URL / external id, so the
  conversion rides in the transformation (e.g. f_webp) instead. Keeps one
  invariant: result.format is set iff the URL carries a trailing .<format>.

- fetch remote URLs are now smart-escaped before going into the path, so
  query strings and fragments (?sig=...#v1) are percent-encoded and reach
  Cloudinary as the source URL instead of being split off by the browser.
  Path-safe chars stay readable, mirroring the Cloudinary SDKs.

- Multi-Step descriptor: step-specific fields are gated by displayOptions
  on stepType/cropMode, and the Quality field gets its QUALITY_OPTIONS so
  an Optimize step can pick a quality level.

- Comments: drop internal/working-context detail (delivery-code claim and
  "see plan") in favor of public, observed behavior.

- Release: publish with --provenance and set n8n-workflow peerDependency
  to "*" per the n8n community-node standard.

Adds regression tests for stored-vs-fetch extension handling and for
fetch URL escaping (query string, fragment, spaces).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Based on an agentic-POV review of what an AI agent actually sees when
building a flow with this node:

- Node description now advertises transform/optimize/deliver and video
  player capabilities, not just upload/manage — it is the agent's first
  filter when the node is offered as a tool (usableAsTool).
- Each Transform operation description states its output (secure_url plus a
  reusable transformation string; embed_url plus player_config for the Video
  Player), so an agent can chain steps without running the node first.
- The delivery type field leads with the recommended "Upload" and makes the
  signed-URL limitation prominent (those delivery URLs fail to load).
  Description only — no behavior change.
- Resource selector options get one-line purpose descriptions, the legacy
  resource is relabeled "Asset (Legacy, by Public ID)" and its operations
  marked Deprecated, reducing wrong-resource picks (e.g. Library was easy to
  mistake for asset listing).
- Resize/Crop field help notes they do not auto-optimize, and to chain
  Optimize Image or use Multi-Step for f_auto/q_auto.

All changes are UI / tool-schema text — no saved-workflow contract impact.
The Multi-Step Quality dropdown flagged during review was already fixed in
e3d0730, so no change was needed there.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replaces the hand-rolled buildDeliveryUrl() with @cloudinary/url-gen so the
SDK owns URL path construction and appends the ?_a= analytics slug automatically
(sdkCode I = n8n, product B = Integrations, registered in Appendix C of the
Cloudinary SDK analytics spec).

Key details:
- forceVersion: false suppresses the SDK default v1 on nested public IDs
- fixFetchUrl() post-processes fetch URLs to encode & and # that the SDK leaves
  bare, preventing CDN/browser misparse of the fetch source URL
- Analytics is correctly suppressed when the fetch source URL contains a
  literal ? (per the Cloudinary analytics spec)
- ? in non-fetch public IDs now encodes as %3F — correct since a bare ? in a
  delivery path starts the query string at the CDN level

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
A Cloudinary CNAME (secure_distribution) is only available on private-CDN
accounts, so the two fields always appear together in a real account. Hiding
secureDistribution behind privateCdn: true in the credential UI prevents the
impossible state and lets the SDK config be a direct translation of credentials
with no inference needed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
n8n does not mandate zero runtime dependencies for community nodes — the rule
was a project-level choice that is now superseded. Future maintainers will see
the dependencies section in package.json directly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant