Skip to content

fix(media): generate and render LQIP placeholders for production media - #1628

Merged
ascorbic merged 4 commits into
emdash-cms:mainfrom
MA2153:feat/media-metadata-enrichment
Jun 30, 2026
Merged

fix(media): generate and render LQIP placeholders for production media#1628
ascorbic merged 4 commits into
emdash-cms:mainfrom
MA2153:feat/media-metadata-enrichment

Conversation

@MA2153

@MA2153 MA2153 commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

What does this PR do?

Image dimensions and LQIP placeholders (blurhash, dominant color) were only ever generated for direct local-storage uploads. Every other server-side media-creation path produced records without them, so production media (R2/S3, which uses signed-URL uploads) silently had no placeholders and the <Image> component had nothing to render before load.

This routes the remaining server-side creation paths through a single shared enrichImageMetadata helper so records are populated consistently:

  • Signed-URL confirm — generates dimensions + LQIP via a storage read-back on confirm.
  • Plugin ctx.media.upload() — enriches records created by plugins.
  • WordPress import — enriches imported media.
  • REST upload — preserves client-supplied width/height on non-image uploads (previous behavior was dropping them).

The placeholders are then cached on the stored media value of content fields (alongside width/height) and on images inserted into rich text, so the <Image> component and portable-text image blocks render a blur/color placeholder before the image loads — with no runtime lookup.

No new public capability is added; this fills a gap in existing functionality (the LQIP/placeholder infrastructure already existed for local uploads) and makes the behavior consistent across paths.

Known limitations

  • Blur/color placeholders are JPEG/PNG only. The server-side generator decodes pixels with jpeg-js and upng-js (pure-JS, workerd-safe), which only cover JPEG and PNG. Other raster formats — WebP, AVIF, GIF, TIFF, etc. — upload normally and record their dimensions (read from headers via image-size, which understands far more formats), but ship without a blur/color placeholder; generatePlaceholder returns null for them and enrichment degrades gracefully. This is easy to miss because WebP is now a default output format for many tools. Broadening coverage to "any raster format the browser can render" would mean computing the LQIP client-side (canvas/createImageBitmap) and sending it through, on both the direct and signed-URL/confirm paths — deferred to a follow-up.

Out of scope (intentionally)

  • Seed $media enrichment. The seed loader's $media reference path (packages/core/src/seed/) also creates media records and is not routed through enrichImageMetadata here. Wiring it up touches the seed download/apply pipeline and its fixtures, which is a separate change; leaving it out keeps this PR focused on the live upload paths. Existing seed width/height handling is unchanged.
  • Backfilling existing media. This does not retroactively populate placeholders for media already in the database. Records are enriched on lookup where possible (normalizeMediaValue pulls newer provider data), but no migration/backfill is included.

Closes #

Type of change

  • Bug fix
  • Feature (requires maintainer-approved Discussion)
  • Refactor (no behavior change)
  • Translation
  • Documentation
  • Performance improvement
  • Tests
  • Chore (dependencies, CI, tooling)

Checklist

  • I have read CONTRIBUTING.md
  • pnpm typecheck passes
  • pnpm lint passes
  • pnpm test passes (or targeted tests for my change)
  • pnpm format has been run
  • I have added/updated tests for my changes (if applicable)
  • User-visible strings in the admin UI are wrapped for translation (if applicable). Do not include messages.po changes except in translation PRs — a workflow extracts catalogs on merge to main. (n/a — no new user-facing admin strings)
  • I have added a changeset (if this PR changes a published package)
  • New features link to an approved Discussion (n/a — bug fix, not a feature)

AI-generated code disclosure

  • This PR includes AI-generated code — model/tool: Claude Opus 4.8 (Claude Code)

Screenshots / test output

Test Files  13 passed (13)
     Tests  133 passed (133)   # packages/core media suites
     Tests    2 passed (2)     # packages/admin editor/image-lqip

🤖 Generated with Claude Code

Image dimensions and LQIP placeholders (blurhash, dominant color) were
only generated for direct local-storage uploads. Signed-URL uploads,
plugin ctx.media.upload(), and WordPress import produced records without
them, so production (R2/S3) media had no placeholders.

Route every server-side media-creation path through a shared
enrichImageMetadata helper, cache the placeholders on the stored media
value of content fields and on rich-text images, and render them in the
<Image> component and portable-text image blocks.
@changeset-bot

changeset-bot Bot commented Jun 26, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 0dcf6c3

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 16 packages
Name Type
emdash Patch
@emdash-cms/cloudflare Patch
@emdash-cms/sandbox-workerd Patch
@emdash-cms/fixture-perf-site Patch
@emdash-cms/perf-demo-site Patch
@emdash-cms/cache-demo-site Patch
@emdash-cms/do-demo-site Patch
@emdash-cms/do-solo-demo-site Patch
@emdash-cms/admin Patch
@emdash-cms/auth Patch
@emdash-cms/blocks Patch
@emdash-cms/gutenberg-to-portable-text Patch
@emdash-cms/x402 Patch
create-emdash Patch
@emdash-cms/auth-atproto Patch
@emdash-cms/plugin-embeds Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions

Copy link
Copy Markdown
Contributor

Scope check

This PR changes 1,410 lines across 33 files. Large PRs are harder to review and more likely to be closed without review.

If this scope is intentional, no action needed. A maintainer will review it. If not, please consider splitting this into smaller PRs.

See CONTRIBUTING.md for contribution guidelines.

@pkg-pr-new

pkg-pr-new Bot commented Jun 26, 2026

Copy link
Copy Markdown

Open in StackBlitz

@emdash-cms/admin

npm i https://pkg.pr.new/@emdash-cms/admin@1628

@emdash-cms/auth

npm i https://pkg.pr.new/@emdash-cms/auth@1628

@emdash-cms/auth-atproto

npm i https://pkg.pr.new/@emdash-cms/auth-atproto@1628

@emdash-cms/blocks

npm i https://pkg.pr.new/@emdash-cms/blocks@1628

@emdash-cms/cloudflare

npm i https://pkg.pr.new/@emdash-cms/cloudflare@1628

@emdash-cms/contentful-to-portable-text

npm i https://pkg.pr.new/@emdash-cms/contentful-to-portable-text@1628

emdash

npm i https://pkg.pr.new/emdash@1628

create-emdash

npm i https://pkg.pr.new/create-emdash@1628

@emdash-cms/gutenberg-to-portable-text

npm i https://pkg.pr.new/@emdash-cms/gutenberg-to-portable-text@1628

@emdash-cms/plugin-cli

npm i https://pkg.pr.new/@emdash-cms/plugin-cli@1628

@emdash-cms/plugin-types

npm i https://pkg.pr.new/@emdash-cms/plugin-types@1628

@emdash-cms/registry-client

npm i https://pkg.pr.new/@emdash-cms/registry-client@1628

@emdash-cms/registry-lexicons

npm i https://pkg.pr.new/@emdash-cms/registry-lexicons@1628

@emdash-cms/sandbox-workerd

npm i https://pkg.pr.new/@emdash-cms/sandbox-workerd@1628

@emdash-cms/x402

npm i https://pkg.pr.new/@emdash-cms/x402@1628

@emdash-cms/plugin-ai-moderation

npm i https://pkg.pr.new/@emdash-cms/plugin-ai-moderation@1628

@emdash-cms/plugin-atproto

npm i https://pkg.pr.new/@emdash-cms/plugin-atproto@1628

@emdash-cms/plugin-audit-log

npm i https://pkg.pr.new/@emdash-cms/plugin-audit-log@1628

@emdash-cms/plugin-color

npm i https://pkg.pr.new/@emdash-cms/plugin-color@1628

@emdash-cms/plugin-embeds

npm i https://pkg.pr.new/@emdash-cms/plugin-embeds@1628

@emdash-cms/plugin-field-kit

npm i https://pkg.pr.new/@emdash-cms/plugin-field-kit@1628

@emdash-cms/plugin-forms

npm i https://pkg.pr.new/@emdash-cms/plugin-forms@1628

@emdash-cms/plugin-webhook-notifier

npm i https://pkg.pr.new/@emdash-cms/plugin-webhook-notifier@1628

commit: 0dcf6c3

@github-actions

Copy link
Copy Markdown
Contributor

Overlapping PRs

This PR modifies files that are also changed by other open PRs:

This may cause merge conflicts or duplicated work. A maintainer will coordinate.

@ascorbic ascorbic added the bot:review Trigger an emdashbot code review on this PR label Jun 26, 2026

@emdashbot emdashbot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a focused, well-structured bug fix: it centralizes image metadata enrichment in enrichImageMetadata, routes the signed-URL confirm path, plugin uploads, WordPress imports, and REST uploads through it, and threads the resulting LQIP fields through the rendering and editor seams. The changeset and tests are sensible, and the approach fits EmDash's architecture.

However, the new helper forwards client-supplied width/height to generatePlaceholder as the OOM guard dimensions. generatePlaceholder was changed in this PR to trust caller-supplied dimensions instead of preferring header-derived ones, so a malicious client can claim a 1×1 image for a huge file and bypass the 32 MB decoded-size cap, causing the Worker/Node process to allocate an unbounded RGBA buffer and OOM. The REST upload (media.ts) and signed-URL confirm (confirm.ts) both pass browser/client dimensions as knownDimensions, so both paths are affected.

The fix is to server-read header dimensions for the OOM guard while still using the client dimensions for the stored record (so the EXIF-orientation fix is preserved). I’ve left a line-anchored suggestion on packages/core/src/media/enrich.ts.

No other blocking issues. The editor round-trips, component fallbacks, local-runtime embedding, and normalize backfill all look correct, and the tests cover the main happy paths. It would be good to add a unit test that exercises the guard with a small-bytes/large-dimensions fixture (or mocked dimensions), but the code fix is the important part.

Comment thread packages/core/src/media/enrich.ts Outdated
enrichImageMetadata fed caller-supplied knownDimensions into
generatePlaceholder's decoded-size cap. Since knownDimensions is
decoupled from the buffer, a client could claim a tiny size for a huge
image and bypass the guard, making the decoder allocate an unbounded
RGBA buffer and OOM the runtime.

Read header dimensions from the bytes that are actually decoded and use
only those for the guard; keep knownDimensions for the stored record so
the EXIF-orientation fix is preserved.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@github-actions github-actions Bot added review/needs-rereview Author pushed changes since the last review and removed review/needs-review No maintainer or bot review yet labels Jun 26, 2026
@ascorbic ascorbic added bot:review Trigger an emdashbot code review on this PR and removed bot:review Trigger an emdashbot code review on this PR labels Jun 30, 2026

@emdashbot emdashbot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The previous review raised one blocking concern: the new enrichImageMetadata helper forwarded client-supplied width/height to generatePlaceholder as the OOM-guard dimensions, allowing a malicious client to claim a tiny size for a huge image and bypass the 32 MB decoded-size cap. That has been fixed.

In the current revision:

  • packages/core/src/media/enrich.ts now reads header dimensions from the actual bytes once (readDimensions) and passes only those header dimensions to generatePlaceholder.
  • Caller-supplied knownDimensions are used only for the stored record width/height, preserving the EXIF-orientation fix without affecting the decode guard.
  • media.ts and confirm.ts both route through this helper, so neither path trusts browser/client dimensions for allocation.
  • A unit test was added that exercises the bypass: a 3000×3000 JPEG with client-supplied 1×1 dimensions correctly gets skipped for LQIP while still recording the client dimensions.

I re-read the affected routes (media.ts, confirm.ts), the plugin upload seam (context.ts), WordPress import (media.ts), the local runtime, normalization, the rendering components, and the editor conversion code. No new blocking issues:

  • Authorization and ownership checks in confirm.ts are unchanged and run before any download.
  • The signed-URL read-back is capped at 8 MB before buffering, with a second in-memory size check.
  • LQIP fields are correctly promoted to first-class fields on MediaValue, MediaProviderItem, image embeds, and portable-text image blocks, with meta fallbacks for legacy snapshots.
  • Backfill via normalizeMediaValue is gated to images and only consults the provider when LQIP is missing.
  • Tests cover the confirm read-back, REST upload, plugin upload, WordPress import, normalization backfill, and the OOM guard.
  • The changeset and localization handling match the PR’s scope; no new user-facing admin strings were added.

Approving as the blocker is resolved and the rest of the change is sound.

@ascorbic
ascorbic merged commit d4237eb into emdash-cms:main Jun 30, 2026
45 checks passed
@emdashbot emdashbot Bot mentioned this pull request Jun 30, 2026
marcusbellamyshaw-cell pushed a commit to Emdash-Bug-Testing/emdash that referenced this pull request Jul 22, 2026
emdash-cms#1628)

* fix(media): generate and render LQIP placeholders for production media

Image dimensions and LQIP placeholders (blurhash, dominant color) were
only generated for direct local-storage uploads. Signed-URL uploads,
plugin ctx.media.upload(), and WordPress import produced records without
them, so production (R2/S3) media had no placeholders.

Route every server-side media-creation path through a shared
enrichImageMetadata helper, cache the placeholders on the stored media
value of content fields and on rich-text images, and render them in the
<Image> component and portable-text image blocks.

* fix(media): use header dims, not client dims, for placeholder OOM guard

enrichImageMetadata fed caller-supplied knownDimensions into
generatePlaceholder's decoded-size cap. Since knownDimensions is
decoupled from the buffer, a client could claim a tiny size for a huge
image and bypass the guard, making the decoder allocate an unbounded
RGBA buffer and OOM the runtime.

Read header dimensions from the bytes that are actually decoded and use
only those for the guard; keep knownDimensions for the stored record so
the EXIF-orientation fix is preserved.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
marcusbellamyshaw-cell pushed a commit to Emdash-Bug-Testing/emdash that referenced this pull request Jul 22, 2026
emdash-cms#1628)

* fix(media): generate and render LQIP placeholders for production media

Image dimensions and LQIP placeholders (blurhash, dominant color) were
only generated for direct local-storage uploads. Signed-URL uploads,
plugin ctx.media.upload(), and WordPress import produced records without
them, so production (R2/S3) media had no placeholders.

Route every server-side media-creation path through a shared
enrichImageMetadata helper, cache the placeholders on the stored media
value of content fields and on rich-text images, and render them in the
<Image> component and portable-text image blocks.

* fix(media): use header dims, not client dims, for placeholder OOM guard

enrichImageMetadata fed caller-supplied knownDimensions into
generatePlaceholder's decoded-size cap. Since knownDimensions is
decoupled from the buffer, a client could claim a tiny size for a huge
image and bypass the guard, making the decoder allocate an unbounded
RGBA buffer and OOM the runtime.

Read header dimensions from the bytes that are actually decoded and use
only those for the guard; keep knownDimensions for the stored record so
the EXIF-orientation fix is preserved.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/admin area/core bot:review Trigger an emdashbot code review on this PR cla: signed overlap review/needs-rereview Author pushed changes since the last review size/XL

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants