Appearance
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):
| State | r2_key | remote_url | Bytes live | Rendered from |
|---|---|---|---|---|
| App-owned (upload) | set | NULL | app R2 | /media/[id] transform endpoint |
| Referenced (synced Drupal) | NULL | set | source origin | images.nanawall.com |
| Imported (was referenced) | set | set (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_urlis 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_urlis left in place as inert provenance. - Bytes-in-app win: whenever
r2_keyis present the app owns a copy, so the imported state renders identically to a plain upload.remote_urlis 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'stoMediaItemsurfacesremoteUrlonly whenr2_keyis NULL (seesrc/lib/server/admin/media.ts). So the view model already encodes "bytes-in-app win": an imported asset arrives withremoteUrl: nulland 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 viaMediaFrame/MediaGalleryon home, compare, select), theMediaManagerpicker, and the/admin/mediagrid. None callmediaSrcdirectly.
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 onsource_ref, so idempotent re-sync is unchanged. - The apply step (
applyMediaSync) callscreateReferencedAsset(metadata +remote_url, no R2 write) instead of downloading +createImageAsset. It needs no R2 bucket and nofetch. - Existing downloaded Drupal rows are reconciled, not orphaned. A pre-#384 row has
r2_keyset andremote_urlNULL; the planner matches it bysource_refand 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:
referencedImportBlockgates the request from asset state alone (pure, unit-tested): missing asset / non-image / no source URL / missing bucket are refused; an asset that already hasr2_keyreturnsalreadyImported— a no-op, so re-import is idempotent.- 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 setr2_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.
Related
- Media library query — the
sourcefilter, pagination, and indexes the tabs consume. - Media tagging — the
(tag_type, tag_ref)product facet. - Media presentation — the cohesive frame the resolved images render inside.
- #365 — the sync reshaped here; #359 — Entity Presentation.