Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/media-metadata-enrichment.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"emdash": patch
---

Fixes missing image dimensions and LQIP placeholders (blurhash, dominant color) on media created through signed-URL uploads, plugin `ctx.media.upload()`, and WordPress import. These were only generated for direct (local-storage) uploads, so production (R2/S3) media had no placeholders.

LQIP placeholders are now 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 without a runtime lookup.
9 changes: 9 additions & 0 deletions packages/admin/src/components/ImageFieldRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { Image as ImageIcon, ImageBroken, X } from "@phosphor-icons/react";
import * as React from "react";

import type { MediaItem } from "../lib/api";
import { metaString } from "../lib/media-utils";
import { MediaPickerModal } from "./MediaPickerModal";

/**
Expand All @@ -30,6 +31,10 @@ export interface ImageFieldValue {
alt?: string;
width?: number;
height?: number;
/** LQIP blurhash placeholder (images only) */
blurhash?: string;
/** LQIP dominant-color placeholder, as a CSS color (images only) */
dominantColor?: string;
/** Provider-specific metadata */
meta?: Record<string, unknown>;
}
Expand Down Expand Up @@ -85,6 +90,10 @@ export function ImageFieldRenderer({
alt: item.alt || "",
width: item.width,
height: item.height,
// Cache LQIP alongside dimensions so embeds render a placeholder without a
// runtime lookup. Fall back to `meta` for providers that stash it there.
blurhash: item.blurhash ?? metaString(item.meta, "blurhash"),
dominantColor: item.dominantColor ?? metaString(item.meta, "dominantColor"),
meta: isLocalProvider ? { ...item.meta, storageKey: item.storageKey } : item.meta,
});
};
Expand Down
36 changes: 35 additions & 1 deletion packages/admin/src/components/PortableTextEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -136,11 +136,15 @@ interface PortableTextTextBlock {
interface PortableTextImageBlock {
_type: "image";
_key: string;
asset: { _ref: string; url?: string };
asset: { _ref: string; url?: string; meta?: Record<string, unknown> };
alt?: string;
caption?: string;
width?: number;
height?: number;
/** LQIP blurhash — first-class field (legacy snapshots store it in `asset.meta`). */
blurhash?: string;
/** LQIP dominant color — first-class field (legacy snapshots store it in `asset.meta`). */
dominantColor?: string;
displayWidth?: number;
displayHeight?: number;
alignment?: "left" | "center" | "right" | "wide" | "full";
Expand Down Expand Up @@ -306,6 +310,13 @@ function convertPMNode(node: {
case "image": {
const attrs = node.attrs ?? {};
const provider = attrStr(attrs.provider);
const blurhash = attrStr(attrs.blurhash);
const dominantColor = attrStr(attrs.dominantColor);
// Persist LQIP as first-class block fields, matching the image-field
// path (MediaValue.blurhash/dominantColor) so read sites and normalize
// don't need a `asset.meta` dual-shape. `asset.meta` is left to carry
// only provider-specific data (we don't reconstruct it here, so any
// non-LQIP meta keys are never silently dropped on editor round-trip).
return {
_type: "image",
_key: generateKey(),
Expand All @@ -318,6 +329,8 @@ function convertPMNode(node: {
caption: attrStr(attrs.caption) ?? attrStr(attrs.title),
width: attrNum(attrs.width),
height: attrNum(attrs.height),
...(blurhash ? { blurhash } : {}),
...(dominantColor ? { dominantColor } : {}),
displayWidth: attrNum(attrs.displayWidth),
displayHeight: attrNum(attrs.displayHeight),
alignment: attrStr(attrs.alignment) as PortableTextImageBlock["alignment"],
Expand Down Expand Up @@ -652,6 +665,21 @@ function convertPTBlock(block: PortableTextBlock): unknown {
case "image": {
if (!isImageBlock(block)) return null;
const imageBlock = block;
const meta = imageBlock.asset.meta;
// Prefer first-class LQIP fields; fall back to `asset.meta` for legacy
// snapshots persisted before LQIP was promoted out of the provider meta bag.
const blurhash =
typeof imageBlock.blurhash === "string"
? imageBlock.blurhash
: typeof meta?.blurhash === "string"
? meta.blurhash
: null;
const dominantColor =
typeof imageBlock.dominantColor === "string"
? imageBlock.dominantColor
: typeof meta?.dominantColor === "string"
? meta.dominantColor
: null;
return {
type: "image",
attrs: {
Expand All @@ -662,6 +690,8 @@ function convertPTBlock(block: PortableTextBlock): unknown {
mediaId: imageBlock.asset._ref,
width: imageBlock.width,
height: imageBlock.height,
blurhash,
dominantColor,
displayWidth: imageBlock.displayWidth,
displayHeight: imageBlock.displayHeight,
alignment: imageBlock.alignment,
Expand Down Expand Up @@ -2379,6 +2409,8 @@ export function PortableTextEditor({
provider: item.provider || "local",
width: item.width,
height: item.height,
blurhash: item.blurhash,
dominantColor: item.dominantColor,
})
.run();
}
Expand Down Expand Up @@ -2882,6 +2914,8 @@ function EditorToolbar({
mediaId: item.id,
width: item.width,
height: item.height,
blurhash: item.blurhash,
dominantColor: item.dominantColor,
})
.run();
},
Expand Down
6 changes: 6 additions & 0 deletions packages/admin/src/components/editor/ImageDetailPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ export interface ImageAttributes {
width?: number;
/** Original image height */
height?: number;
/** LQIP blurhash placeholder */
blurhash?: string;
/** LQIP dominant-color placeholder */
dominantColor?: string;
/** Display width for this instance (defaults to original) */
displayWidth?: number;
/** Display height for this instance (defaults to original) */
Expand Down Expand Up @@ -115,6 +119,8 @@ export function ImageDetailPanel({
mediaId: item.id,
width: item.width,
height: item.height,
blurhash: item.blurhash,
dominantColor: item.dominantColor,
// Clear caption/title since it's a new image
caption: undefined,
title: undefined,
Expand Down
14 changes: 14 additions & 0 deletions packages/admin/src/components/editor/ImageNode.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ declare module "@tiptap/react" {
provider?: string;
width?: number;
height?: number;
/** LQIP blurhash placeholder */
blurhash?: string;
/** LQIP dominant-color placeholder */
dominantColor?: string;
displayWidth?: number;
displayHeight?: number;
alignment?: "left" | "center" | "right" | "wide" | "full";
Expand Down Expand Up @@ -79,6 +83,8 @@ function ImageNodeView({ node, updateAttributes, selected, deleteNode, editor }:
mediaId: node.attrs.mediaId,
width: node.attrs.width,
height: node.attrs.height,
blurhash: node.attrs.blurhash,
dominantColor: node.attrs.dominantColor,
displayWidth: node.attrs.displayWidth,
displayHeight: node.attrs.displayHeight,
alignment: node.attrs.alignment,
Expand Down Expand Up @@ -342,6 +348,12 @@ export const ImageExtension = Node.create({
height: {
default: null,
},
blurhash: {
default: null,
},
dominantColor: {
default: null,
},
displayWidth: {
default: null,
},
Expand Down Expand Up @@ -382,6 +394,8 @@ export const ImageExtension = Node.create({
provider?: string;
width?: number;
height?: number;
blurhash?: string;
dominantColor?: string;
displayWidth?: number;
displayHeight?: number;
alignment?: "left" | "center" | "right" | "wide" | "full";
Expand Down
8 changes: 8 additions & 0 deletions packages/admin/src/lib/api/media.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ export interface MediaItem {
size: number;
width?: number;
height?: number;
/** LQIP blurhash placeholder (images only) */
blurhash?: string;
/** LQIP dominant-color placeholder, as a CSS color (images only) */
dominantColor?: string;
alt?: string;
caption?: string;
createdAt: string;
Expand Down Expand Up @@ -302,6 +306,10 @@ export interface MediaProviderItem {
size?: number;
width?: number;
height?: number;
/** LQIP blurhash placeholder (images only) */
blurhash?: string;
/** LQIP dominant-color placeholder, as a CSS color (images only) */
dominantColor?: string;
alt?: string;
previewUrl?: string;
meta?: Record<string, unknown>;
Expand Down
12 changes: 12 additions & 0 deletions packages/admin/src/lib/media-utils.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
import type { MediaItem, MediaProviderItem } from "./api/media.js";

/** Read a string value from an untyped `meta` bag, or undefined. */
export function metaString(
meta: Record<string, unknown> | undefined,
key: string,
): string | undefined {
const value = meta?.[key];
return typeof value === "string" ? value : undefined;
}

export function providerItemToMediaItem(
providerId: string,
item: MediaProviderItem,
Expand All @@ -12,6 +21,9 @@ export function providerItemToMediaItem(
size: item.size || 0,
width: item.width,
height: item.height,
// Prefer first-class fields; some providers stash LQIP in `meta`.
blurhash: item.blurhash ?? metaString(item.meta, "blurhash"),
dominantColor: item.dominantColor ?? metaString(item.meta, "dominantColor"),
alt: item.alt,
createdAt: new Date().toISOString(),
provider: providerId,
Expand Down
81 changes: 81 additions & 0 deletions packages/admin/tests/editor/image-lqip.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
/**
* Admin editor image LQIP round-trip.
*
* blurhash/dominantColor must survive Portable Text ↔ ProseMirror conversion so
* author-inserted images keep their placeholders. LQIP is persisted as
* first-class block fields (matching the image-field path); `asset.meta` is
* only a read fallback for legacy snapshots.
*/

import { describe, it, expect } from "vitest";

import {
_portableTextToProsemirror as portableTextToProsemirror,
_prosemirrorToPortableText as prosemirrorToPortableText,
} from "../../src/components/PortableTextEditor";

type ImagePMNode = { type: string; attrs?: Record<string, unknown> };
type ImagePTBlock = {
_type: string;
asset?: { meta?: { blurhash?: string; dominantColor?: string } };
blurhash?: string;
dominantColor?: string;
};

describe("admin editor image LQIP round-trip", () => {
it("preserves blurhash and dominantColor through PT → PM → PT (promotes legacy asset.meta)", () => {
const block = {
_type: "image" as const,
_key: "img1",
asset: {
_ref: "01ABC",
url: "/_emdash/api/media/file/01ABC.jpg",
meta: { blurhash: "LEHV6nWB2yk8pyo0adR*.7kCMdnj", dominantColor: "#aabbcc" },
},
alt: "A photo",
width: 1200,
height: 800,
};

// eslint-disable-next-line typescript/no-unsafe-type-assertion -- test fixture
const pm = portableTextToProsemirror([block as never]);
const node = pm.content?.[0] as ImagePMNode;
expect(node.type).toBe("image");
expect(node.attrs?.blurhash).toBe("LEHV6nWB2yk8pyo0adR*.7kCMdnj");
expect(node.attrs?.dominantColor).toBe("#aabbcc");

const pt = prosemirrorToPortableText({ type: "doc", content: pm.content });
const restored = pt[0] as ImagePTBlock;
expect(restored._type).toBe("image");
// Promoted to first-class fields; asset.meta no longer carries them.
expect(restored.blurhash).toBe("LEHV6nWB2yk8pyo0adR*.7kCMdnj");
expect(restored.dominantColor).toBe("#aabbcc");
expect(restored.asset?.meta?.blurhash).toBeUndefined();
expect(restored.asset?.meta?.dominantColor).toBeUndefined();
});

it("preserves first-class LQIP through PT → PM → PT", () => {
const block = {
_type: "image" as const,
_key: "img1b",
asset: { _ref: "01AB2", url: "/_emdash/api/media/file/01AB2.jpg" },
alt: "A photo",
width: 1200,
height: 800,
blurhash: "L6PZfSi_.AyE_3t7t7R**0o#DgR4",
dominantColor: "#112233",
};

// eslint-disable-next-line typescript/no-unsafe-type-assertion -- test fixture
const pm = portableTextToProsemirror([block as never]);
const node = pm.content?.[0] as ImagePMNode;
expect(node.attrs?.blurhash).toBe("L6PZfSi_.AyE_3t7t7R**0o#DgR4");
expect(node.attrs?.dominantColor).toBe("#112233");

const pt = prosemirrorToPortableText({ type: "doc", content: pm.content });
const restored = pt[0] as ImagePTBlock;
expect(restored.blurhash).toBe("L6PZfSi_.AyE_3t7t7R**0o#DgR4");
expect(restored.dominantColor).toBe("#112233");
expect(restored.asset?.meta?.blurhash).toBeUndefined();
});
});
12 changes: 9 additions & 3 deletions packages/core/src/astro/routes/api/import/wordpress/media.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { apiError, apiSuccess, handleError } from "#api/error.js";
import { isParseError, parseBody } from "#api/parse.js";
import { wpMediaImportBody } from "#api/schemas.js";
import { validateExternalUrl, ssrfSafeFetch, SsrfError } from "#import/ssrf.js";
import { enrichImageMetadata } from "#media/enrich.js";
import type { EmDashHandlers } from "#types";

import type { AttachmentInfo } from "./analyze.js";
Expand Down Expand Up @@ -125,7 +126,7 @@ export const POST: APIRoute = async ({ request, locals }) => {
}
};

async function importMediaWithProgress(
export async function importMediaWithProgress(
attachments: AttachmentInfo[],
db: NonNullable<EmDashHandlers["db"]>,
storage: NonNullable<EmDashHandlers["storage"]>,
Expand Down Expand Up @@ -273,15 +274,20 @@ async function importMediaWithProgress(
contentType,
});

// Derive dimensions + LQIP placeholders (no-op for non-images).
const enriched = await enrichImageMetadata(new Uint8Array(buffer), contentType);

// Create media record with content hash
const mediaItem = await repo.create({
filename: attachment.filename || `media-${attachment.id}${ext}`,
mimeType: contentType,
size,
storageKey,
contentHash,
width: undefined,
height: undefined,
width: enriched.width,
height: enriched.height,
blurhash: enriched.blurhash,
dominantColor: enriched.dominantColor,
});

// Build the new URL
Expand Down
Loading
Loading