Media System Architecture & Guide
In-depth technical architecture and feature guide of the SveltyCMS Media & DAM Engine.
On this page
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.
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
-
Service Layer (
media-service.server.ts):getPublishedReferences()scans all collection schemas for entries withstatus: "publish"that reference a given media ID (by ID, path, URL, or embedded object reference). -
Handler Layer (
handlers/media.ts): AcheckMediaNotReferencedByPublishedContent()helper is called in all 6 mutation handlers before performing the operation. Returns 409 Conflict with details identifying which content entries reference the asset. -
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. -
Server Load (
+page.server.ts): BatchedPromise.allSettledpre-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 toPOST /api/media/streamwith thefolderfield preserved. Both paths share one hard ceiling: the dispatcher bounds the request atAPI_MAX_BODY_SIZE_BYTES(15 MB,Content-Lengthand chunked alike) andparseMultipartStreamreceives the same cap formaxFileSize/maxTotalSize, aborting incrementally with413 PAYLOAD_TOO_LARGEinstead 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: falserestores the legacy routing. - Direct-to-storage (S3 / R2):
POST /api/media/upload-instructionsissues a 15-minute SigV4 presignedPUTfor a tenant-scoped staging key plus an HMAC token;POST /api/media/direct-upload/completedownloads 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()insrc/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, nomime-types/mrmime) inspects magic bytes. Uploads useisAllowedUploadMime()(fromMIME_MAP, minus scriptable/office types) andassertMimeAgreement()at write time./filesresolves Content-Type via extension β stored MIME β 512-byte sniff. Remote imports andsaveRemoteMediashareresolveRemoteAssetMime(). All media responses sendX-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 byPOST /api/media/processand the SDK, not on every serve. The deep pass is more than a header read:metadata()plusstats()and aresize(32,32).webp()thumbnail (a full decode). Results are cached 24 h per file hash;fastPath: truereturns the header-only fields (format,width,height,space,channels) without the decode. - Derivative ladder (SIZES):
saveResized()(media-storage.server.ts) writes one output per configuredSIZESstep 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 (pluswithoutEnlargementat 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. Thethumbnailsmap records the encoderβs real output dimensions; steps that were clamped away are simply absent, whichmediaUrl/mediaDisplayUrlfall 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/defaultpresets under{tenantId}/{hash}/variants/{preset}-{width}.{format}through a single-flight FIFO coalescer (variant-job-scheduler.server.ts, bounded bySVELTY_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 withmetadata.processingStatus: "processing"; the job flips it toready(orfailed, withprocessingError) 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.getByHashwithtenantIdβ 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 patchedpathstays 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,metadatamerged) 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
CloudStorageabstraction. - 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, soIf-None-Matchcan still 304); local storage streams the bytes itself. Because a dedupe hit writes nothing, theW/"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.sveltekeeps 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 andIMAGE_SIZESare 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()inmedia-models.ts), so derivatives are never skipped over a typo. - Transparency is preserved: JPEG has no alpha channel, so a source whose decoded
hasAlphais true is written as WebP for that ladder instead of being flattened onto the encoderβs background. - WebP sidecars belong to
originalmode β the pre-2026-09 pairing of a source-format primary with a modern-format alternative. An explicitjpeg/webp/avifwrites exactly one file per step; a per-request alternative is available from the delivery route (/files/**?fmt=webp, orAcceptnegotiation). - Unchanged: the original upload is stored byte-for-byte and never re-encoded, and the 32 px LQIP lives on its own rule (
metadata.placeholderstays a WebP data-URI,media-processing.server.ts).
π On-Demand Image Transforms
Added 2026-09-22 β
/files/**honoursw/h/q/fmtagain 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 byMAX_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()insrc/services/media/image-processor.ts), so quality stays consistent. - Both ladders live in
src/services/media/image-processor.ts; parsing is the pureparseTransformParams(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)βt0means βheight derived from the source aspect ratioβ.hashis 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) andX-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
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 expansionbeginMediaDrag/endMediaDragβ DataTransfer + multi-item ghostmoveMediaToFolderβPOST /api/media/move+mediaMoveddocument 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 forfolderCreated/folderUpdated/folderDeletedevents and the error-state retry. - No two-stage paint.
media-folders.svelterenders the loading state untilhasLoaded, so a cold session never paints a childless βMedia Rootβ that then expands. - One tree chrome.
media-folders.svelteandcollections.svelteboth consume the sameTreeViewstyling (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βsonClickowns navigation, otherwisestopPropagation()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:
- Info β Inline editable name, alt text, caption, and tags (
PATCH /api/media/{id}). - Versions β Upload new version, restore historical copies, compare two versions with field-level diffs.
- References β Live scan of collection entries referencing the asset (
GET /api/media/references/{id}). - 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.
- Immediate Success: The API returns a
202 Acceptedstatus along with anOperationID. - Background Execution: The
Job Queue Servicepicks up the task and executes it across multiple worker threads. - Polling/SSE: The client polls the
/api/media/jobs/:idendpoint or listens via SSE for the completion event. - Metadata Update: Once complete, the
MediaItemmetadata in the database is atomically updated. - Crash recovery: Every polling worker reaps jobs that have been stuck in
runningfor 15 minutes. They are requeued withpending, or markedfailedonce 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:
- Binary corruption:
TextDecoderβTextEncoderround-trip replaced non-UTF8 bytes with replacement characters - 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.hashto 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
- Architecture Overview
- Media Reference (API) β
POST /api/media/move, folders, DAM endpoints - Media Sharing Endpoint β share links survive virtual folder moves
- Security Overview
- State Management