Skip to content

Referenced Drupal media & the display-source contract

The Drupal half of the media library is referenced-from-source, not copied-into-the-app (#384, part of the media-management capability #376). Drupal imagery lives at images.nanawall.com (imgix today, Cloudflare Image Transforms later) and is available to NanaSelect for display without being stored in it. An explicit import action pulls the bytes into the app's R2 when we want the app to own a specific asset.

This reshapes the #365 media sync, which previously downloaded every Drupal image into R2 — a large, mostly-wasted copy at Drupal scale (#376: tens of thousands).

The three states of a media asset

Every row in media_assets is in exactly one of these states, distinguished by two columns — r2_key (app-owned bytes) and remote_url (a source reference):

Stater2_keyremote_urlBytes liveRendered from
App-owned (upload)setNULLapp R2/media/[id] transform endpoint
Referenced (synced Drupal)NULLsetsource originimages.nanawall.com
Imported (was referenced)setset (inert)app R2/media/[id] transform endpoint
  • A referenced asset is catalogued (type, alt/title, source, source_ref) but holds no app-stored bytes. remote_url is the canonical source file URL the sync recorded without copying.
  • Import is the one-way transition referenced → imported: it downloads the bytes, writes R2, and sets r2_key. remote_url is left in place as inert provenance.
  • Bytes-in-app win: whenever r2_key is present the app owns a copy, so the imported state renders identically to a plain upload. remote_url is ignored.

The display-source contract

Exactly one resolver decides where an image renders — displaySrc / displaySrcset in src/lib/media/variants.ts:

displaySrc(item, size, format)
  item.remoteUrl set  → referencedSrc(remoteUrl, width, format)   // images.nanawall.com
  otherwise           → mediaSrc(item.id, size, format)           // /media/[id] endpoint
  • The client never sees r2_key. Instead the server's toMediaItem surfaces remoteUrl only when r2_key is NULL (see src/lib/server/admin/media.ts). So the view model already encodes "bytes-in-app win": an imported asset arrives with remoteUrl: null and the resolver picks the app path — no caller changes when an asset is imported.
  • Every rendering surface goes through the resolver: ResponsiveImage (all public galleries via MediaFrame/MediaGallery on home, compare, select), the MediaManager picker, and the /admin/media grid. None call mediaSrc directly.

referencedSrc — the only imgix-aware code

referencedSrc(remoteUrl, width, format)
  → https://images.nanawall.com{path of remoteUrl}?w={width}&fit=max&auto=compress&fm={format}

The source file path is mirrored onto the CDN host (imgix pulls the original from the Drupal origin at the same path); fit=max never upscales past the source, auto=compress lets the CDN choose quality, and fm forces the output format so each <picture> source (AVIF → WebP → JPEG) caches as its own object.

This is the single point of change for the imgix → Cloudflare Image Transforms swap. Nothing else in the app knows the CDN's URL shape.

The reshaped sync (reference-only)

src/lib/server/drupal/media-sync.ts catalogs Drupal media as references and never downloads bytes:

  • The pure planner (planMediaSync) is unchanged — same create/attach/unchanged diff keyed on source_ref, so idempotent re-sync is unchanged.
  • The apply step (applyMediaSync) calls createReferencedAsset (metadata + remote_url, no R2 write) instead of downloading + createImageAsset. It needs no R2 bucket and no fetch.
  • Existing downloaded Drupal rows are reconciled, not orphaned. A pre-#384 row has r2_key set and remote_url NULL; the planner matches it by source_ref and leaves it untouched, and the resolver renders it from the app path (bytes in app). Old and new rows coexist.

On-demand import

importReferencedAsset (in admin/media.ts), exposed as the import action on /admin/media/manage and the "Import to app" button on referenced rows in /admin/media:

  1. referencedImportBlock gates the request from asset state alone (pure, unit-tested): missing asset / non-image / no source URL / missing bucket are refused; an asset that already has r2_key returns alreadyImported — a no-op, so re-import is idempotent.
  2. Otherwise: fetch remote_url, validate the image type against the upload allowlist, parse dimensions, strip EXIF/XMP/IPTC (same as an upload — the source may carry GPS metadata), write R2, and set r2_key + dimensions + size. Provenance (source='drupal', source_ref) is preserved.

App / Drupal tabs

The /admin/media library screen splits into All / App / Drupal tabs over the source filter that #375 added (manual = App uploads + imports, drupal = synced references). The tabs preserve the other filters and reset to page 1; the picker's source <select> serves the same split in the compact inline panel.