Skip to content

Documentation

Media System Architecture & Guide

In-depth technical architecture and feature guide of the SveltyCMS Media & DAM Engine.

10/19/2026
29 min read Edit on GitHub

This page is the storage and processing pipeline. Cropping in the admin is the Image editor. Upload and download routes are the Media API.

The SveltyCMS Media system is a decoupled, performance-first engine. Built with Svelte 5 and Sharp.js, it prioritizes native Node/Bun utilities over heavy third-party dependencies to ensure sub-millisecond latency and reduced bundle size.


πŸ—οΈ Layered Architecture

The system follows a strict 4-layer architecture to ensure storage and framework portability.

graph TB subgraph "Client Layer (Svelte 5 Runes)" UI[Media Gallery UI] GRID[media-grid.svelte] TABLE[media-table.svelte] FOLDERS[media-folders.svelte] CRUMB[Breadcrumb drop targets] DND[media-dnd.ts] MODAL[media-details-modal.svelte] UW[local-upload.svelte] UPLOAD[upload-client.ts] DASH[media-storage-analytics-widget] EDITOR[ImageEditor Shell] WGPU[Canvas 2D Optimizer] HLS[hls-video-player.svelte] TRANSCODE[transcode-modal.svelte] end subgraph "API Layer (src/routes/api/[...path])" API1[GET /api/media] API2[POST /api/media/stream] API3[GET /api/media/analytics] API4[GET /api/media/bulk-download] API5[GET /api/media/version/compare] API6[POST /api/media/move] API7[system-virtual-folder] end subgraph "Media Engine (src/utils/media/)" MS[media-service.server.ts] MPS[media-processing.server.ts] MST[media-storage.server.ts] SA[storage-adapters.ts] SHARING[sharing.ts + bulk-download] SNIFF[slim-sniffer.server.ts] ANALYTICS[storage-analytics.ts] STREAM[streaming-upload.ts] VERSIONS[version-history β†’ media-storage] DNDUTIL[media-dnd.ts helpers] UTILS[media-utils.ts] end subgraph "Infrastructure" JOBS[Job Queue Service] DB[(Database)] STORE[Storage: Local/S3/R2] end UI --> GRID UI --> TABLE UI --> CRUMB GRID --> DND TABLE --> DND FOLDERS --> DND CRUMB --> DND DND --> API6 FOLDERS --> API7 UI --> MODAL MODAL --> API5 UI --> UPLOAD UW --> UPLOAD UPLOAD --> API2 UPLOAD --> API1 DASH --> API3 UI --> API4 UI --> EDITOR WGPU --> UW EDITOR --> MS API2 --> MS API6 --> DB API3 --> ANALYTICS API4 --> BULK API5 --> VERSIONS MS --> MPS MS --> MST MST --> CS MS --> JOBS MS --> DB CS --> STORE

JSON Path Media Filter

Gallery filtering supports a small JSON-path expression language (client + server + DB).

Layer Location Behavior
Client +page.svelte + json-path-filter.ts Instant filter on loaded items
Server +page.server.ts ?jsonPath= Passes jsonPath into getByFolder; post-filters SSR payload
Database media-json-path.ts + adapters Native pushdown for metadata.* on large libraries

Syntax

metadata.camera = Canon
metadata.camera ~ canon
metadata.iso >= 400
metadata.tags[0] = nature
metadata.camera = Canon; metadata.iso > 100

Operators: = / ==, !=, ~ / *= (contains), >, <, >=, <=. Multi-clause AND via ; or &&.

Shareable URL example: /mediagallery?folderId=…&jsonPath=metadata.camera%20%3D%20Canon

DB-native pushdown (large libraries)

When options.jsonPath is set on media.files.getByFolder:

Adapter Mechanism
SQLite JSON1 json_extract(metadata, '$.…')
PostgreSQL jsonb metadata->> / metadata#>>
MariaDB/MySQL JSON_UNQUOTE(JSON_EXTRACT(metadata, …))
MongoDB Dotted-path filters + case-insensitive $regex

Only simple metadata.* paths (no array indices) are pushed to the DB. Array paths (metadata.tags[0]) and non-metadata fields stay as in-memory post-filters so pagination remains correct when every clause is native.

Implementation:

  • Parser: src/utils/json-path-filter.ts
  • SQL/Mongo builders: src/databases/core/media-json-path.ts
  • Unit: tests/unit/utils/json-path-filter.test.ts, tests/unit/databases/media-json-path.test.ts
  • Integration: tests/integration/api/media-jsonpath.test.ts (?jsonPath= reduces load payload)

Upload Progress & Cancel

Feature Implementation
Progress % XHR upload.progress in upload-client.ts
Per-file batch Sequential upload when onFileProgress / sequential: true
Cancel AbortSignal + MediaUploadHandle.cancel()
UI Gallery toolbar + local-upload.svelte progress bar + Cancel

Large batches still use /api/media/stream when over STREAM_UPLOAD_THRESHOLD_BYTES (10β€―MB).


πŸ›‘οΈ Published-Reference Protection (Media Integrity Gate)

The media system prevents mutation of assets referenced by published content entries. This protects against broken references on live pages.

How It Works

  1. Service Layer (media-service.server.ts): getPublishedReferences() scans all collection schemas for entries with status: "publish" that reference a given media ID (by ID, path, URL, or embedded object reference).

  2. Handler Layer (handlers/media.ts): A checkMediaNotReferencedByPublishedContent() helper is called in all 6 mutation handlers before performing the operation. Returns 409 Conflict with details identifying which content entries reference the asset.

  3. UI Layer (media-grid.svelte, media-table.svelte): Edit/delete buttons are disabled with contextual tooltip β€œReferenced by published content”. A lock badge appears at the top-left of affected media cards.

  4. Server Load (+page.server.ts): Batched Promise.allSettled pre-checks all visible media items for published references before rendering.

Gated Operations

Handler HTTP Method Response
Direct delete DELETE /api/media/:id 409 Conflict
POST delete POST /api/media/delete 409 Conflict
Manipulate POST /api/media/manipulate 409 Conflict
Version create POST /api/media/version 409 Conflict
Version upload POST /api/media/version/upload 409 Conflict
Version restore POST /api/media/version/restore 409 Conflict

Error Response Format

{
  "error": {
    "message": "Cannot modify asset: referenced by published content - \"Blog Post\" in \"Posts\" (featuredImage) and 2 more",
    "code": "MEDIA_REFERENCED_BY_PUBLISHED_CONTENT"
  }
}

πŸš€ The Media Pipeline (4-Stage Workflow)

SveltyCMS employs a high-performance ingestion and delivery pipeline designed for massive scale and resilience.

Phase I: Ingestion (Client-Side & Hashing)

  • Canvas 2D Optimization: [Client-Only] Large images are downscaled to a 1920Γ—1080 bounding box and re-encoded to WebP directly in the browser before upload, saving bandwidth (local-upload.svelte β†’ canvas-image-optimizer.ts). No GPU API is involved; browser WebGPU is only used by the LiteRT.js AI client.
  • Smart Upload Routing (upload-client.ts): The gallery and dedicated upload page route payloads automatically β€” files or batches under 10 MB use the SvelteKit form action (?/upload); larger payloads stream to POST /api/media/stream with the folder field preserved. Both paths share one hard ceiling: the dispatcher bounds the request at API_MAX_BODY_SIZE_BYTES (15 MB, Content-Length and chunked alike) and parseMultipartStream receives the same cap for maxFileSize/maxTotalSize, aborting incrementally with 413 PAYLOAD_TOO_LARGE instead of buffering toward the parser defaults. Files between 6 MB and 15 MB take the TUS resumable lane (POST /api/media/tus + PATCH) so a reload or network drop resumes from the last committed offset; resumable: false restores the legacy routing.
  • Direct-to-storage (S3 / R2): POST /api/media/upload-instructions issues a 15-minute SigV4 presigned PUT for a tenant-scoped staging key plus an HMAC token; POST /api/media/direct-upload/complete downloads the staged object, enforces the declared size, re-sniffs MIME (polyglot rejection), and runs the normal ingestion path. Local and Cloudinary installs fall back to TUS because no privileged storage endpoint is exposed. Contract: Direct & Resumable Uploads.
  • Hash-First Approach: Every file is identified by the full 64-character hex SHA-256 digest of its bytes (hashFileContent() in src/utils/media/media-processing.server.ts). The hash is both the deduplication key and the storage directory of the file: {tenantId}/{hash}/original/<name>-<hash>.<ext>. The lookup happens before any derivative work and is scoped to the tenant β€” see Storage & Deduplication below.
  • SlimSniffer + unified allowlist: slim-sniffer.server.ts (~2KB, no mime-types/mrmime) inspects magic bytes. Uploads use isAllowedUploadMime() (from MIME_MAP, minus scriptable/office types) and assertMimeAgreement() at write time. /files resolves Content-Type via extension β†’ stored MIME β†’ 512-byte sniff. Remote imports and saveRemoteMedia share resolveRemoteAssetMime(). All media responses send X-Content-Type-Options: nosniff.

Phase II: Processing (Deep Metadata & AI)

  • Deep Metadata Extraction (opt-in): EXIF/IPTC/XMP, dominant colour and the 32 px WebP placeholder are extracted by MediaProcessingService.getMetadata() β€” called by POST /api/media/process and the SDK, not on every serve. The deep pass is more than a header read: metadata() plus stats() and a resize(32,32).webp() thumbnail (a full decode). Results are cached 24 h per file hash; fastPath: true returns the header-only fields (format, width, height, space, channels) without the decode.
  • Derivative ladder (SIZES): saveResized() (media-storage.server.ts) writes one output per configured SIZES step into {tenantId}/{size}/<name>-<hash>.<ext> β€” JPEG by default, see Derivative output format for the setting, the opt-ins and the measured cost. Two invariants hold: a step wider than the decoded source is dropped (plus withoutEnlargement at the encoder), so a 400 px icon produces the 200 px step only, and each resolved path is written once β€” a source that is already WebP no longer encodes its own primary twice. The thumbnails map records the encoder’s real output dimensions; steps that were clamped away are simply absent, which mediaUrl/mediaDisplayUrl fall back from. Measured 2026-09-22: a 400 px source went from 8 files / 16,984 B to 2 files / 600 B.
  • Responsive variants (deferred coalescer): after the record exists, deferred jobs derive thumbnail/card/default presets under {tenantId}/{hash}/variants/{preset}-{width}.{format} through a single-flight FIFO coalescer (variant-job-scheduler.server.ts, bounded by SVELTY_VARIANT_JOBS, default 2). Concurrent uploads of identical bytes share a single preset pipeline run, and bulk imports queue in waves to protect memory and CPU. Every upload lands with metadata.processingStatus: "processing"; the job flips it to ready (or failed, with processingError) in the media document. Full consolidation of this pass into the SIZES ladder is tracked in 2026 Roadmap.
  • AI-Native Tagging [WIP]: A conceptual background task that hooks into local Ollama (llava) for privacy-first image analysis.
  • Document Thumbnails: Automated extraction of PDF first-pages (requires imagemagick).

Phase III: Storage & Deduplication

  • Deduplication Engine: After the hash is computed (and before any derivative work), the service looks the hash up scoped to the tenant (media.files.getByHash with tenantId β€” no cross-tenant reuse). On a hit it verifies that the record’s original and every derivative path it advertises still exist; when they do, the existing record is reused, the deferred variant job is never scheduled, and the upload returns after at most one plain file copy (only when the same bytes arrive under a different filename, so the patched path stays truthful). When a file is missing β€” remove() unlinks files rather than directories, and a GC sweep can drop them β€” the row is repaired in place (original rewritten, ladder re-derived, metadata merged) instead of being returned with URLs that 404, and never duplicated into a second row.
  • Hybrid Storage: Supports Local, S3, and R2 storage backends via the CloudStorage abstraction.
  • ETag/304 Handling: Unified ETag strategy ensures high-performance browser caching. Cloud backends answer /files/** with a 302 redirect to the CDN public URL (with the object ETag when available, so If-None-Match can still 304); local storage streams the bytes itself. Because a dedupe hit writes nothing, the W/"size-mtime" ETag of an unchanged asset survives a re-upload β€” clients are not forced to re-download identical bytes.

Phase IV: Delivery & Transformation

  • On-Demand Transforms: /files/**?w=&h=&q=&fmt= produces cached raster variants on the delivery route (no separate /api/media/* transform endpoint exists). See On-Demand Image Transforms below for the parameter contract, clamps, cache layout and fallback rules.
  • Focal Points: Visual crosshair coordinates are injected into delivery APIs to support art-directed cropping in the frontend.
  • Lazy Loading: lazy thumbnail decoding in media-grid.svelte / media-table.svelte keeps the gallery responsive with thousands of files using Svelte 5 runes.

Processing status, version retention & cleanup

Derivatives are content-addressed ({tenantId}/{hash}/…), so replacing a source with new bytes always produces a new key β€” a cached transform can never serve a stale original. The lifecycle layer (src/utils/media/media-lifecycle.server.ts) makes the surrounding metadata and storage collection honest:

Concern Behaviour
Status metadata.processingStatus is processing until every deferred rendition lands, then ready; a failed derivation keeps the original servable and records processingError. Non-raster assets are ready immediately. The gallery exposes a status facet (ready / processing / failed), and the details modal shows the same value with the failure reason.
Replacement Uploading a new version or saving an edit commits the new content hash and the matching thumbnails / metadata.imageVariants, so the record never advertises the replaced file’s renditions. The outgoing derivative set is captured on the version entry.
Restore Restoring a version reinstates its captured thumbnails and imageVariants. Legacy entries without that capture are cleared and regenerated from the restored bytes (status returns to processing until the job lands).
Reference-aware retention media:cleanup keeps the newest keepVersions entries per asset (default 10) and releases a content hash only when no record still points at it β€” as the active original or through any retained version. Shared hashes are never released; variant directories and originals are deleted together.
Deletion Deleting a media record releases its hashes only when no sibling record or retained version still references them.
Admin commands POST /api/media/cleanup and POST /api/media/sync-status run outside the job queue, so they work with DISABLE_JOBS or a stuck worker. sync-status derives status from actual derivative evidence and can re-queue missing work (requeue: true).

The cleanup pass is dry-run first by design: { "dryRun": true } reports the version count and hash list it would release without touching the DB or storage.

Derivative output format

Upload-time derivatives (the SIZES ladder) are written in JPEG by default: the encoder β€” not the resize β€” dominates ladder cost, and JPEG is the cheapest encode of the three formats this pipeline can write.

Operation (sharp 0.35.x) Cost
AVIF 640 β€” the most expensive 102–143 ms
JPEG 1920 40–57 ms
4-step ladder (parallel encodes) 40–57 ms
Full 1080p upload (original + ladder + metadata) 104–117 ms
WebP 320 q82 (reference point) 24 ms

Self-measured, 2026-09-27, this machine. The ladder runs its steps in parallel, so its wall time is the slowest single encode: JPEG at every step costs the same as the JPEG 1920 row, while AVIF is the most expensive option β€” 102–143 ms at 640 px alone, before any other step finishes.

The format comes from the public setting MEDIA_OUTPUT_FORMAT_QUALITY ({ format, quality }, quality default 80):

format Primary output per step WebP sidecar ({key}_webp)
jpeg (default), jpg JPEG β€” image/jpeg, .jpg no
webp WebP β€” image/webp n/a (the primary is WebP)
avif AVIF β€” image/avif no
original the source format (pre-2026-09) yes
  • Opting into WebP/AVIF β€” set the same record, e.g. { "format": "webp", "quality": 80 } or { "format": "avif", "quality": 50 }. The setup wizard seeds { "format": "jpeg", "quality": 80 } for new installs; the value is stored per tenant with the other public settings (setPublicSetting / cms.system.settings.set). Nested-object settings such as this one and IMAGE_SIZES are not part of the System Settings form yet.
  • Existing installs keep what they stored. A site that already saved { "format": "webp" } keeps producing WebP until the value is changed β€” a stored setting always wins over the default.
  • Fail safe: an absent or unrecognised format (e.g. heic) falls back to JPEG (normalizeDerivativeFormat() in media-models.ts), so derivatives are never skipped over a typo.
  • Transparency is preserved: JPEG has no alpha channel, so a source whose decoded hasAlpha is true is written as WebP for that ladder instead of being flattened onto the encoder’s background.
  • WebP sidecars belong to original mode β€” the pre-2026-09 pairing of a source-format primary with a modern-format alternative. An explicit jpeg/webp/avif writes exactly one file per step; a per-request alternative is available from the delivery route (/files/**?fmt=webp, or Accept negotiation).
  • Unchanged: the original upload is stored byte-for-byte and never re-encoded, and the 32 px LQIP lives on its own rule (metadata.placeholder stays a WebP data-URI, media-processing.server.ts).

πŸš€ On-Demand Image Transforms

Added 2026-09-22 β€” /files/** honours w/h/q/fmt again with a real resize. Before this, the route ignored those parameters entirely and transferred the full original under any URL.

Transforms are cached: the variant is generated exactly once (single-flight), written next to the original, and every later request streams it from disk with an ETag + Cache-Control: immutable. Without w/h the route streams the original byte-for-byte β€” the parameters are opt-in and never change a plain asset URL.

Parameters

Param Aliases Accepted values Behaviour
w β€” dimension ladder step Target width, snapped down to the nearest step
h β€” dimension ladder step Target height; with w it becomes a bounding box (fit: inside, aspect preserved)
q β€” quality ladder step Encoder quality, snapped to the nearest step (default 82)
fmt format webp, avif, jpeg/jpg, png Explicit output format; any other value (e.g. svg, gif) rejects the transform
Accept β€” image/avif, image/webp, image/jpeg Used (in q= order) only when fmt/format is absent; wildcards mean β€œkeep the source”
  • Dimension ladder (TRANSFORM_DIMENSION_STEPS): 16, 24, 32, 48, 64, 96, 128, 160, 192, 240, 320, 384, 480, 640, 768, 960, 1280, 1600, 1920, 2560, 3840, filtered by MAX_OUTPUT_DIMENSION (6000 px default). Requests are clamped down to a step and never upscaled (withoutEnlargement), so a caller cannot allocate an arbitrary resolution or fill the variant cache with one file per pixel width.
  • Quality ladder (TRANSFORM_QUALITY_STEPS): 50, 60, 70, 75, 80, 82, 85, 90. Encoder settings are shared with the upload-time variants (applyEncoder() in src/services/media/image-processor.ts), so quality stays consistent.
  • Both ladders live in src/services/media/image-processor.ts; parsing is the pure parseTransformParams(searchParams, accept, sourceMime) helper.
/files/global/<hash>/original/photo-<hash>.jpg?w=300          β†’ 240 px wide, source format
/files/global/<hash>/original/photo-<hash>.jpg?w=640&h=480&q=75
/files/global/<hash>/original/photo-<hash>.jpg?w=1280&fmt=avif
/files/global/<hash>/original/photo-<hash>.jpg?w=320          + Accept: image/webp β†’ WebP variant

Cache layout

Variants reuse the existing variant store (src/services/media/image-variant-storage.ts):

{tenantId}/{hash}/variants/t{height}q{quality}-{width}.{format}
# e.g. global/9f2c…/variants/t0q82-320.webp
  • The key is (hash, width, height, quality, format) β€” t0 means β€œheight derived from the source aspect ratio”. hash is the content hash of the original bytes, so a variant can never belong to another asset.
  • The key is derived from the original’s tenant-scoped path, never from a signed URL’s signature: authorization stays with the route’s tenant gate + signature validation (both run before any transform work), and differently-signed requests for the same tenant safely share one entry.
  • Variant responses carry their own weak ETag, Last-Modified, immutable, Vary: Accept (the negotiated format depends on the request header) and X-Media-Transform: w{width}h{height}q{quality}.{format} for observability.

What is never transformed

Source / request Result
SVG Original (sanitised at upload; /files keeps its CSP script-src 'none' path)
GIF (animated or still) Original β€” animation must not be flattened
Animated WebP (VP8X animation flag / ANIM chunk) Original β€” header sniffed, never re-encoded
Animated AVIF (avis brand) Original β€” header sniffed
TIFF, BMP, HEIC, PDF, video, unknown types Original
Files above MAX_TRANSFORM_SOURCE_BYTES (40 MB) Original β€” bounds the one-shot read + decode
More than MAX_CONCURRENT_TRANSFORMS (8) distinct variants generating at once Original for the excess β€” bounds aggregate decode memory; the rest generate on a later request
Paths that are not {tenant}/{hash}/original/… Original β€” only canonical originals have a variant key
Explicit unencodable fmt/format Original β€” a format we cannot encode is never faked
No w/h, or both below the smallest step Original

Every skip logs at debug level and falls back to streaming the original β€” a broken or hostile ?w= can never fail the request (encoder errors, disk errors, oversized sources included).

Storage backends

  • Local storage: transforms are generated and cached in Node (Sharp), one generation per variant, single-flight coalesced.
  • s3 / r2 / cloudinary: unchanged β€” the route keeps its 302 redirect to the CDN public URL before any byte work, so transform parameters never pull the object through Node; on Cloudinary/Fastly-style CDNs the equivalent transform is applied CDN-side.

Where the code lives

Concern Location
Query parsing, ladders, encoder settings src/services/media/image-processor.ts
Variant cache path + writes src/services/media/image-variant-storage.ts
Route (gates β†’ transform β†’ stream), single-flight map src/routes/files/[...path]/+server.ts
Unit coverage tests/unit/media/on-demand-transform-params.test.ts, tests/unit/media/files-route-transform.test.ts

πŸ“ Virtual Folders & Drag-and-Drop Moves

Assets live in virtual folders: a logical folderId on each media row, not a physical directory tree. Folder definitions are stored as system virtual folders (/api/system-virtual-folder). Moving media updates metadata only β€” blobs and share URLs stay put.

Folder-tree mutations are cycle-guarded server-side: PATCH reorder rejects a move into the folder itself or one of its descendants, and the recursive path updater carries a visited set so a corrupt hierarchy cannot cause unbounded recursion.

Data model

Field on media Meaning
folderId Virtual folder ID, or null / unset for media root
path / url Storage-relative / public URL (unchanged by moves)

Move pipeline

sequenceDiagram participant Grid as media-grid / media-table participant Target as Sidebar folder OR breadcrumb participant DND as media-dnd.ts participant API as POST /api/media/move participant DB as media.files.move Grid->>Grid: beginMediaDrag(ids) multi-select aware Grid->>Target: drop application/x-sveltycms-media-ids Target->>DND: moveMediaToFolder(ids, targetFolderId) DND->>API: JSON fileIds + targetFolderId API->>DB: UPDATE folderId (tenant-scoped) DB-->>API: movedCount DND-->>Grid: mediaMoved event β†’ optimistic UI + invalidateAll

Equivalent drop targets

On desktop, sidebar and breadcrumb are first-class peers β€” same payload, same API, same result:

Drop surface Component / location Targets available
Sidebar virtual folder tree media-folders.svelte (left sidebar) Full tree + Media Root
Gallery path breadcrumbs mediagallery/+page.svelte Ancestor crumbs (incl. Media Gallery)
Drag source Notes
media-grid.svelte Thumbnail cards; multi-select moves the whole set when dragged item is selected
media-table.svelte Desktop table + mobile list rows; same multi-select rules

Mobile: limited room for the sidebar β€” users drag (or select + tap) onto breadcrumb parents. Desktop users may use either surface interchangeably.

Shared client module: src/utils/media/media-dnd.ts

  • MIME: application/x-sveltycms-media-ids
  • resolveMediaDragIds β€” Windows Explorer–style selection expansion
  • beginMediaDrag / endMediaDrag β€” DataTransfer + multi-item ghost
  • moveMediaToFolder β€” POST /api/media/move + mediaMoved document event

Server: handleMediaMove β†’ MediaNamespace.move β†’ dbAdapter.media.files.move.

Left sidebar context

On /mediagallery, the left sidebar shows virtual folders and a back-to-collections control (not dual Collections + Media section headers). On collection routes, only the collections tree is shown. See left-sidebar.svelte.

The folder list is owned by mediaFolderTree (src/stores/media-folder-tree.svelte.ts), a module singleton:

  • One fetch per session. ensureLoaded() is used for first paint; moving between folders only updates ?folderId/the selected row, so the tree never refetches on navigation. load() is reserved for folderCreated / folderUpdated / folderDeleted events and the error-state retry.
  • No two-stage paint. media-folders.svelte renders the loading state until hasLoaded, so a cold session never paints a childless β€œMedia Root” that then expands.
  • One tree chrome. media-folders.svelte and collections.svelte both consume the same TreeView styling (row surface, primary selection, indent, chevrons, compact rail) β€” media no longer has a separate visual variant.
  • SPA navigation. Tree rows render as <a> for hover preload; the row handler cancels the anchor default when the node’s onClick owns navigation, otherwise stopPropagation() lets the browser fall back to a full document reload (which used to wipe the cached tree on every folder click).

πŸ—‚οΈ DAM Admin UI Integration

The DAM API endpoints are wired into native Svelte 5 admin surfaces β€” no third-party asset browser required.

Feature UI Surface API / Utility
Virtual folder move Grid/table drag β†’ sidebar folders or breadcrumb parents POST /api/media/move via media-dnd.ts
Folder tree CRUD Sidebar media-folders.svelte + gallery β€œNew Folder” /api/system-virtual-folder
Bulk archive download Selection toolbar in mediagallery/+page.svelte GET /api/media/bulk-download?id=… β†’ TAR.GZ
Bulk delete Selection toolbar in media.svelte (4Γ— concurrency) POST /api/media/delete (per-item, with publish gate)
Storage analytics Dashboard widget media-storage-analytics-widget.svelte GET /api/media/analytics (60s poll)
Version diff Versions tab in media-details-modal.svelte GET /api/media/version/{id}/compare?from=&to=
Version history Versions tab (upload, restore, download) POST /api/media/version/{id}, /restore, /upload
Processing status Gallery status facet + details modal badge metadata.processingStatus; POST /api/media/sync-status
Lifecycle maintenance Admin/ops command (no UI required) POST /api/media/cleanup (dry-run supported)
Secure share links Share tab in media-details-modal.svelte POST /api/media/share/{id}, DELETE …/{token}
Streaming upload Gallery toolbar + local-upload.svelte upload-client.ts β†’ form action or /api/media/stream
Batch resize / filter Selection toolbar β†’ batch-processor.svelte panel POST /api/media/batch-process (≀100 ids, publish gate)
Adaptive HLS playback Details modal β†’ hls-video-player.svelte metadata.hls.master; native HLS or lazy hls.js
Video transcoding Details modal β†’ Transcode β†’ transcode-modal.svelte POST /api/media/transcode (ffmpeg; 503 when absent)

Media Details Modal

media-details-modal.svelte is the asset control center with four tabs:

  1. Info β€” Inline editable name, alt text, caption, and tags (PATCH /api/media/{id}).
  2. Versions β€” Upload new version, restore historical copies, compare two versions with field-level diffs.
  3. References β€” Live scan of collection entries referencing the asset (GET /api/media/references/{id}).
  4. Share β€” Generate expiring, password-protected public links and revoke active tokens.

πŸ”„ Asynchronous Processing & Job Queue

Heavy processing tasks (AI analysis, bulk variant generation, video transcoding) are decoupled from the main request/response cycle.

  1. Immediate Success: The API returns a 202 Accepted status along with an OperationID.
  2. Background Execution: The Job Queue Service picks up the task and executes it across multiple worker threads.
  3. Polling/SSE: The client polls the /api/media/jobs/:id endpoint or listens via SSE for the completion event.
  4. Metadata Update: Once complete, the MediaItem metadata in the database is atomically updated.
  5. Crash recovery: Every polling worker reaps jobs that have been stuck in running for 15 minutes. They are requeued with pending, or marked failed once their attempts are exhausted, so a killed worker cannot strand a job forever.

🎨 Image Editor Integration

The gallery is deeply integrated with the canvas-based Image Editor:

  • Non-Destructive Editing: Originals are preserved; edits are saved as linked variants.
  • Interactive Focal Points: Visual crosshair selection for art-directed cropping.
  • Watermark Caching: Pre-rendered watermark buffers are cached in-memory to avoid redundant re-scaling.
  • Bounded Output: editor saves are capped at MAX_OUTPUT_DIMENSION (6000 px default, env-overridable). Normal-size edits still encode once; only oversized results take a second downscale pass so watermark/annotation coordinates stay correct.

⚑ Performance Benchmarks

For comprehensive performance details regarding media hashing, metadata extraction, and multi-scale resizing block times, please reference the SQLite Benchmarks.


πŸ“š API Reference

For detailed developer endpoints (upload, transform, move, job polling, focal points), read the comprehensive Media Reference.


πŸ” Media Reference Reverse-Index

Added 2026-07-16 β€” replaces O(n Γ— entries) full-scan with O(1) indexed lookups.

Problem

getMediaReferences() and getPublishedReferences() previously scanned every entry in every collection for each query β€” O(n Γ— entries) per call. On a CMS with 50 collections and 10,000 entries, this meant 500,000 field traversals per reference check.

Solution

MediaReferenceIndex (src/utils/media/media-reference-index.ts) is an in-memory reverse-index that maps media identifiers to the entries that reference them:

// First query triggers a full rebuild (lazy)
// Subsequent queries are O(1) Map lookups
const refs = await mediaService.getMediaReferences(mediaId);
Component Location Purpose
MediaReferenceIndex media-reference-index.ts In-memory Map<mediaId, MediaReference[]>
rebuildReferenceIndex() media-service.server.ts Full scan β†’ populates index, status map, name map
eventBus invalidation Constructor + SystemEvents.CONTENT_UPDATE Auto-clears index on any content mutation
enrichReferences() media-service.server.ts Backfills fieldName/entryName for API consumers

Architecture

Content mutation (create/update/delete)
  β†’ eventBus.emit(CONTENT_UPDATE)
    β†’ referenceIndex.clear()  (cache invalidated)

Next getMediaReferences() call
  β†’ lazy rebuildReferenceIndex()  (one full scan)
    β†’ O(1) lookups for all subsequent queries

Design note: The index is in-memory only. On first query it performs a full scan (rebuild), then subsequent queries are O(1). For production with thousands of entries, consider a periodic rebuild via setInterval or stale-while-revalidate. The eventBus listener ensures the cache is invalidated on any content update.


πŸ“€ Streaming Upload Parser

Added 2026-07-16 — true streaming multipart parser with backpressure, replacing the previous TextDecoder→TextEncoder implementation that corrupted binary uploads.

Problem {#streaming-upload-problem}

The previous streaming-upload.ts had two critical bugs:

  1. Binary corruption: TextDecoder→TextEncoder round-trip replaced non-UTF8 bytes with replacement characters
  2. Not streaming: Read the entire request body into RAM before parsing β€” OOM on large files

Solution {#streaming-upload-solution}

A byte-level state machine (streaming-upload.ts) that processes ReadableStream chunks incrementally:

Feature Implementation
True streaming parser Incremental byte-level parser β€” the multipart framing is never buffered as a whole
Binary-safe Raw Uint8Array chunks pass through untouched
Backpressure Per-file ReadableStream is pulled by the route consumer; the media route buffers the current file into chunks[] (bounded by API_MAX_BODY_SIZE_BYTES) until it is complete, then hands it to the storage adapter β€” framing stays streaming, the part body does not
Size limits maxFileSize / maxTotalSize are set by the route to API_MAX_BODY_SIZE_BYTES (15 MB) β€” the same ceiling the dispatcher enforces for Content-Length and chunked bodies. The parser’s 1 GiB / 5 GiB defaults are never the effective limit on uploads; an oversize part aborts incrementally (413 PAYLOAD_TOO_LARGE) instead of buffering
MIME validation isAllowedUploadMime() on part Content-Type (415); magic-byte agreement after the body is available
Filename sanitization Path separators, control chars, Windows reserved names stripped
Error cleanup Active push streams errored on parser failure β€” no hanging readers
Timeout Configurable per-chunk read timeout (default 300s)
// Real consumer (routes/api/[...path]/handlers/media.ts): the parser surfaces each part's
// ReadableStream; the route accumulates it under the 15 MB cap and writes one buffer β€”
// the StorageAdapter interface is `upload(data, relativePath)`, there is no uploadStream().
await parseMultipartStream(request, {
  onFile: async ({ filename, stream }) => {
    const chunks: Uint8Array[] = [];
    // …read with a running size check, then:
    await getStorageAdapter().upload(Buffer.concat(chunks), `global/${hash}/original/${filename}`);
  },
});

πŸ›‘οΈ Type-Safe Media Models

Refactored 2026-07-16 β€” discriminated union with type guards, DTO types, and exhaustiveness checking.

Design

media-models.ts uses as const maps (not enums) for tree-shakable, JSON-safe value lists. Each map doubles as the runtime valid-value list:

// Type guards β€” narrow discriminated union at runtime
if (isMediaImage(item)) item.width;           // MediaImage
if (isStoredMedia(item)) item.hash;           // StoredMedia (excludes remote video)
if (isMediaOfType(item, MediaType.Video)) item.duration; // MediaVideo

// DTO types β€” server-assigned fields rejected at compile time
const draft: NewMedia<MediaImage> = { ... };  // no _id, hash, url, createdBy
const patch: MediaPatch = { alt: "photo" };   // PATCH-safe fields only

// Exhaustiveness β€” add a new media type and every switch fails to compile
switch (item.type) {
  case MediaType.Image:    return item.width;
  case MediaType.RemoteVideo: return item.provider;
  // ...
  default: return assertNever(item);
}
Improvement Before After
Type safety any everywhere, zero guards Discriminated union + 7 type guards
DTO safety Client could PATCH hash/url/createdBy NewMedia<T>, MediaPatch block server-assigned fields
Exhaustiveness Missing type = silent bug assertNever() catches at compile time
Tree-shaking enum blocks erasableSyntaxOnly as const maps are fully erasable
Watermark positions Sharp-rejected "top"/"bottom" normalizeWatermarkPosition() maps to compass values

πŸš€ Media Caching Layer

The media engine uses process-local TTL caches for repeated deterministic operations. These are separate from the global dual-layer cache (L1 LRU + L2 Redis) β€” media caches are smaller, shorter-lived, and scoped to their module.

Cache Location TTL Max entries Purpose
URL mapping storage-adapters.ts β†’ getUrl() 5 min 10,000 Path β†’ URL resolution for every media item, thumbnail, variant on page render
Metadata media-processing.server.ts β†’ getMetadata() 24 h 5,000 Sharp metadata extraction (dimensions, EXIF, stats) by file SHA-256 hash
File existence (negative) media-storage.server.ts β†’ fileExists() 10 s unbounded Skips adapter lookup for recently checked non-existent paths
Watermark list media-utils.ts β†’ fetchWatermarks() 5 min per-collection Watermark collection query results

Design decisions

  • URL cache: Deterministic β€” same path always produces same URL. 10k entries covers all thumbnails + variants in a typical gallery view. LRU eviction via oldest-entry deletion at threshold.
  • Metadata cache: File hash β†’ metadata map. 24h TTL because image metadata never changes for the same bytes. Callers that already computed the hash (e.g., upload pipeline) pass it via options.hash to skip re-hashing.
  • Negative existence cache: Short 10s TTL β€” prevents repeated adapter calls during upload validation without stale false negatives. Paths a dedupe hit depends on (the original and every derivative it advertises) are verified with refresh: true, so a cached positive can never mask a file that a GC sweep removed.
  • Watermark cache: Watermarks rarely change. 5min TTL avoids re-fetching on every watermark tool open.

All caches use plain Map with timestamp-based expiry β€” no external dependencies, no serialization overhead, zero allocation on hit.


πŸ§ͺ Verification & Benchmarks

The media pipeline’s correctness and latency properties are asserted through dedicated unit suites, integration tests, and benchmark rows:

Feature / Invariant Verification Suite Benchmark Matrix Row (tests/benchmarks/media-performance.test.ts)
Dedupe before generation tests/unit/media/upload-dedupe-order.test.ts, tests/integration/api/media-dedupe-derivatives.test.ts Media: Duplicate Upload (identical bytes) β€” verifies 0 rewrites, stable W/"size-mtime" ETag, ~3 ms vs ~48 ms
Derivative ladder clamping tests/unit/media/derivative-ladder.test.ts, tests/unit/media/processing/media-storage.test.ts Media: Derivative Fan-Out (1920px source) and (400px source) β€” validates sub-ladder sources produce only clamped steps (400px writes 2 files, 600 B vs 8 files, 16.9 KB)
Single-flight coalescing tests/unit/media/variant-job-scheduler.test.ts FIFO bounds via SVELTY_VARIANT_JOBS (default 2), deduplicating identical concurrent uploads to protect memory
On-demand delivery transforms tests/unit/media/on-demand-transform-params.test.ts, tests/unit/media/files-route-transform.test.ts HTTP: On-Demand Transform (cached 320px WebP) β€” single-flight generate-once on miss, stream on hit

Related

architecturemediadamsvelte5ai-taggingvirtual-foldersstreaming-uploadreference-indextype-safety
Was this page helpful?