diff --git a/.changeset/media-metadata-enrichment.md b/.changeset/media-metadata-enrichment.md new file mode 100644 index 0000000000..e770d686b3 --- /dev/null +++ b/.changeset/media-metadata-enrichment.md @@ -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 `` component and portable-text image blocks render a blur/color placeholder before the image loads without a runtime lookup. diff --git a/packages/admin/src/components/ImageFieldRenderer.tsx b/packages/admin/src/components/ImageFieldRenderer.tsx index 463634a6d8..892323b13a 100644 --- a/packages/admin/src/components/ImageFieldRenderer.tsx +++ b/packages/admin/src/components/ImageFieldRenderer.tsx @@ -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"; /** @@ -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; } @@ -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, }); }; diff --git a/packages/admin/src/components/PortableTextEditor.tsx b/packages/admin/src/components/PortableTextEditor.tsx index a0caa14ce9..9b7e680ad8 100644 --- a/packages/admin/src/components/PortableTextEditor.tsx +++ b/packages/admin/src/components/PortableTextEditor.tsx @@ -136,11 +136,15 @@ interface PortableTextTextBlock { interface PortableTextImageBlock { _type: "image"; _key: string; - asset: { _ref: string; url?: string }; + asset: { _ref: string; url?: string; meta?: Record }; 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"; @@ -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(), @@ -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"], @@ -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: { @@ -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, @@ -2379,6 +2409,8 @@ export function PortableTextEditor({ provider: item.provider || "local", width: item.width, height: item.height, + blurhash: item.blurhash, + dominantColor: item.dominantColor, }) .run(); } @@ -2882,6 +2914,8 @@ function EditorToolbar({ mediaId: item.id, width: item.width, height: item.height, + blurhash: item.blurhash, + dominantColor: item.dominantColor, }) .run(); }, diff --git a/packages/admin/src/components/editor/ImageDetailPanel.tsx b/packages/admin/src/components/editor/ImageDetailPanel.tsx index 4d204eec4f..621bc635b3 100644 --- a/packages/admin/src/components/editor/ImageDetailPanel.tsx +++ b/packages/admin/src/components/editor/ImageDetailPanel.tsx @@ -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) */ @@ -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, diff --git a/packages/admin/src/components/editor/ImageNode.tsx b/packages/admin/src/components/editor/ImageNode.tsx index 3b2cf76e2b..a860db880b 100644 --- a/packages/admin/src/components/editor/ImageNode.tsx +++ b/packages/admin/src/components/editor/ImageNode.tsx @@ -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"; @@ -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, @@ -342,6 +348,12 @@ export const ImageExtension = Node.create({ height: { default: null, }, + blurhash: { + default: null, + }, + dominantColor: { + default: null, + }, displayWidth: { default: null, }, @@ -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"; diff --git a/packages/admin/src/lib/api/media.ts b/packages/admin/src/lib/api/media.ts index 29c699fbcd..9e0ccbdf10 100644 --- a/packages/admin/src/lib/api/media.ts +++ b/packages/admin/src/lib/api/media.ts @@ -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; @@ -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; diff --git a/packages/admin/src/lib/media-utils.ts b/packages/admin/src/lib/media-utils.ts index cf3d3e933d..8bed87e48b 100644 --- a/packages/admin/src/lib/media-utils.ts +++ b/packages/admin/src/lib/media-utils.ts @@ -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 | undefined, + key: string, +): string | undefined { + const value = meta?.[key]; + return typeof value === "string" ? value : undefined; +} + export function providerItemToMediaItem( providerId: string, item: MediaProviderItem, @@ -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, diff --git a/packages/admin/tests/editor/image-lqip.test.ts b/packages/admin/tests/editor/image-lqip.test.ts new file mode 100644 index 0000000000..da7247188f --- /dev/null +++ b/packages/admin/tests/editor/image-lqip.test.ts @@ -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 }; +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(); + }); +}); diff --git a/packages/core/src/astro/routes/api/import/wordpress/media.ts b/packages/core/src/astro/routes/api/import/wordpress/media.ts index fef14a49c8..8116a1895f 100644 --- a/packages/core/src/astro/routes/api/import/wordpress/media.ts +++ b/packages/core/src/astro/routes/api/import/wordpress/media.ts @@ -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"; @@ -125,7 +126,7 @@ export const POST: APIRoute = async ({ request, locals }) => { } }; -async function importMediaWithProgress( +export async function importMediaWithProgress( attachments: AttachmentInfo[], db: NonNullable, storage: NonNullable, @@ -273,6 +274,9 @@ 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}`, @@ -280,8 +284,10 @@ async function importMediaWithProgress( size, storageKey, contentHash, - width: undefined, - height: undefined, + width: enriched.width, + height: enriched.height, + blurhash: enriched.blurhash, + dominantColor: enriched.dominantColor, }); // Build the new URL diff --git a/packages/core/src/astro/routes/api/media.ts b/packages/core/src/astro/routes/api/media.ts index 9fa7644432..b1bb4e7b92 100644 --- a/packages/core/src/astro/routes/api/media.ts +++ b/packages/core/src/astro/routes/api/media.ts @@ -16,8 +16,8 @@ import { GLOBAL_UPLOAD_ALLOWLIST, resolveFieldAllowlist } from "#api/handlers/me import { isParseError, parseQuery } from "#api/parse.js"; import { DEFAULT_MAX_UPLOAD_SIZE, formatFileSize, mediaListQuery } from "#api/schemas.js"; import { MediaRepository } from "#db/repositories/media.js"; +import { enrichImageMetadata } from "#media/enrich.js"; import { matchesMimeAllowlist, normalizeMime } from "#media/mime.js"; -import { generatePlaceholder } from "#media/placeholder.js"; import { computeContentHash } from "#utils/hash.js"; import type { MediaItem } from "../../types.js"; @@ -163,34 +163,32 @@ export const POST: APIRoute = async ({ request, locals }) => { const width = widthStr ? parseInt(widthStr, 10) : undefined; const height = heightStr ? parseInt(heightStr, 10) : undefined; - // Generate placeholder data for images. - // If the client sent a thumbnail (small pre-resized image), use that - // instead of the full buffer to avoid OOM on memory-constrained runtimes. + // Derive dimensions + LQIP placeholders via the shared helper. + // If the client sent a downscaled thumbnail, decode that for the blurhash + // (avoids OOM on large originals on memory-constrained runtimes). const thumbnailEntry = formData.get("thumbnail"); const thumbnail = thumbnailEntry instanceof File ? thumbnailEntry : null; - - let placeholder: Awaited> = null; - if (file.type.startsWith("image/")) { - if (thumbnail) { - const thumbBuffer = new Uint8Array(await thumbnail.arrayBuffer()); - placeholder = await generatePlaceholder(thumbBuffer, thumbnail.type); - } else { - const clientDims = width && height ? { width, height } : undefined; - placeholder = await generatePlaceholder(buffer, file.type, clientDims); - } - } + const enriched = await enrichImageMetadata(buffer, file.type, { + knownDimensions: width != null && height != null ? { width, height } : undefined, + placeholder: thumbnail + ? { bytes: new Uint8Array(await thumbnail.arrayBuffer()), contentType: thumbnail.type } + : undefined, + }); // Create media record const result = await emdash.handleMediaCreate({ filename: file.name, mimeType: normalizeMime(file.type), size: file.size, - width, - height, + // Client dimensions win over server header dimensions: the browser's + // naturalWidth/Height apply EXIF orientation, while image-size reports + // raw (pre-orientation) header dims — swapped for 90°/270° JPEGs. + width: width ?? enriched.width, + height: height ?? enriched.height, storageKey, contentHash, - blurhash: placeholder?.blurhash, - dominantColor: placeholder?.dominantColor, + blurhash: enriched.blurhash, + dominantColor: enriched.dominantColor, authorId: user?.id, }); diff --git a/packages/core/src/astro/routes/api/media/[id]/confirm.ts b/packages/core/src/astro/routes/api/media/[id]/confirm.ts index bc081c50c1..944dabf10f 100644 --- a/packages/core/src/astro/routes/api/media/[id]/confirm.ts +++ b/packages/core/src/astro/routes/api/media/[id]/confirm.ts @@ -14,10 +14,20 @@ import { requireOwnerPerm, requirePerm } from "#api/authorize.js"; import { apiError, apiSuccess, handleError } from "#api/error.js"; import { isParseError, parseOptionalBody } from "#api/parse.js"; import { mediaConfirmBody } from "#api/schemas.js"; +import { enrichImageMetadata } from "#media/enrich.js"; import type { MediaItem } from "#types"; export const prerender = false; +/** + * Max raw bytes to buffer for server-side LQIP generation at confirm time. The + * signed-URL upload flow exists so large files bypass server buffering — re-reading + * the whole object into a Worker's 128 MB heap to compute a blurhash would OOM + * on the very uploads that flow was designed for. LQIP is progressive + * enhancement: large images simply ship without a server-generated placeholder. + */ +const MAX_PLACEHOLDER_DOWNLOAD_BYTES = 8 * 1024 * 1024; + /** * Add URL to media item (relative URL for portability) */ @@ -81,11 +91,61 @@ export const POST: APIRoute = async ({ params, request, locals }) => { } } + // For images, read the just-uploaded bytes back from storage once to + // generate LQIP placeholders (and server-side dimensions as a fallback). + // The signed-URL flow uploads directly to storage, so this confirm is the + // only point at which the server sees the bytes. Best-effort: a decode + // failure must not block the upload from being marked ready. We also cap + // the download size — buffering a large original into a Worker heap to + // compute a 32px blurhash would OOM on the uploads the signed-URL path + // exists to support, so oversized files skip the server-side placeholder. + let blurhash: string | undefined; + let dominantColor: string | undefined; + let width = body.width; + let height = body.height; + if (emdash.storage && existing.mimeType.startsWith("image/")) { + const knownSize = body.size ?? existing.size ?? undefined; + const tooLarge = knownSize != null && knownSize > MAX_PLACEHOLDER_DOWNLOAD_BYTES; + if (!tooLarge) { + try { + const { body: stream } = await emdash.storage.download(existing.storageKey); + const bytes = new Uint8Array(await new Response(stream).arrayBuffer()); + // Defense-in-depth for the unknown-size case: even though we + // already buffered it, refuse the decode so we don't also pay + // the (larger) RGBA allocation. + if (bytes.byteLength > MAX_PLACEHOLDER_DOWNLOAD_BYTES) { + console.warn( + `[media] confirm skipping placeholder: object ${existing.storageKey} is ${bytes.byteLength} bytes (> ${MAX_PLACEHOLDER_DOWNLOAD_BYTES})`, + ); + } else { + const enriched = await enrichImageMetadata(bytes, existing.mimeType, { + knownDimensions: + body.width != null && body.height != null + ? { width: body.width, height: body.height } + : undefined, + }); + blurhash = enriched.blurhash; + dominantColor = enriched.dominantColor; + width = width ?? enriched.width; + height = height ?? enriched.height; + } + } catch (error) { + console.error("[media] confirm placeholder generation failed:", error); + } + } else { + console.warn( + `[media] confirm skipping placeholder: object ${existing.storageKey} reported size ${knownSize} bytes (> ${MAX_PLACEHOLDER_DOWNLOAD_BYTES})`, + ); + } + } + // Confirm the upload const item = await repo.confirmUpload(id, { size: body.size, - width: body.width, - height: body.height, + width, + height, + blurhash, + dominantColor, }); if (!item) { diff --git a/packages/core/src/components/EmDashImage.astro b/packages/core/src/components/EmDashImage.astro index 5954f27302..78e32b4abc 100644 --- a/packages/core/src/components/EmDashImage.astro +++ b/packages/core/src/components/EmDashImage.astro @@ -158,15 +158,12 @@ if (img) { } } -// Build placeholder background style +// Build placeholder background style. Prefer the first-class MediaValue fields; +// fall back to `meta` for snapshots stored before LQIP was promoted off `meta`. const blurhash = - typeof image === "object" - ? (image?.meta?.blurhash as string | undefined) - : undefined; + img?.blurhash ?? (img?.meta?.blurhash as string | undefined); const dominantColor = - typeof image === "object" - ? (image?.meta?.dominantColor as string | undefined) - : undefined; + img?.dominantColor ?? (img?.meta?.dominantColor as string | undefined); let placeholderStyle = ""; if (blurhash) { diff --git a/packages/core/src/components/Image.astro b/packages/core/src/components/Image.astro index 0990f5b52c..f5f1e92ab6 100644 --- a/packages/core/src/components/Image.astro +++ b/packages/core/src/components/Image.astro @@ -25,7 +25,8 @@ export interface Props { url?: string; /** Provider ID for external media (e.g., "cloudflare-images") */ provider?: string; - /** Provider metadata (blurhash, dominantColor, etc.) */ + /** Provider metadata. LQIP is read from first-class fields below; + * `meta` only remains as a fallback for legacy snapshots. */ meta?: Record; }; alt?: string; @@ -34,6 +35,10 @@ export interface Props { width?: number; /** Original image height */ 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; /** Display width for this instance (overrides original) */ displayWidth?: number; /** Display height for this instance (overrides original) */ @@ -152,9 +157,14 @@ if (!src) { } } -// Build placeholder background style -const blurhash = asset.meta?.blurhash as string | undefined; -const dominantColor = asset.meta?.dominantColor as string | undefined; +// Build placeholder background style. Prefer first-class LQIP fields; fall +// back to `asset.meta` for legacy snapshots persisted before LQIP was promoted. +const blurhash = (node.blurhash ?? (asset.meta?.blurhash as string | undefined)) as + | string + | undefined; +const dominantColor = (node.dominantColor ?? (asset.meta?.dominantColor as string | undefined)) as + | string + | undefined; let placeholderStyle = ""; if (blurhash) { diff --git a/packages/core/src/components/InlinePortableTextEditor.tsx b/packages/core/src/components/InlinePortableTextEditor.tsx index c818aa5077..4f015c7709 100644 --- a/packages/core/src/components/InlinePortableTextEditor.tsx +++ b/packages/core/src/components/InlinePortableTextEditor.tsx @@ -200,6 +200,13 @@ function convertPMNode(node: PMNode): PTBlock | PTBlock[] | null { } case "image": { const provider = attrStrOpt(node.attrs, "provider"); + const blurhash = attrStrOpt(node.attrs, "blurhash"); + const dominantColor = attrStrOpt(node.attrs, "dominantColor"); + // Persist LQIP as first-class block fields (matching the image-field + // MediaValue path) rather than nesting in `asset.meta`, so read sites + // and normalize don't need a dual-shape fallback. `asset.meta` is left + // to carry only provider-specific data — it isn't reconstructed here, + // so non-LQIP meta keys are never silently dropped on editor round-trip. return { _type: "image", _key: k(), @@ -212,6 +219,8 @@ function convertPMNode(node: PMNode): PTBlock | PTBlock[] | null { caption: attrStrOpt(node.attrs, "caption") ?? attrStrOpt(node.attrs, "title"), width: attrNum(node.attrs, "width"), height: attrNum(node.attrs, "height"), + ...(blurhash ? { blurhash } : {}), + ...(dominantColor ? { dominantColor } : {}), displayWidth: attrNum(node.attrs, "displayWidth"), displayHeight: attrNum(node.attrs, "displayHeight"), }; @@ -416,16 +425,38 @@ function convertPTBlock(block: PTBlock): JSONContent | null { } if (block._type === "image") { const ib = block as PTBlock & { - asset?: { _ref?: string; url?: string; provider?: string }; + asset?: { + _ref?: string; + url?: string; + provider?: string; + meta?: Record; + }; url?: string; alt?: string; caption?: string; width?: number; height?: number; + /** LQIP — first-class field (legacy snapshots keep it in `asset.meta`). */ + blurhash?: string; + dominantColor?: string; displayWidth?: number; displayHeight?: number; }; const asset = ib.asset; + const meta = asset?.meta; + // Prefer first-class LQIP fields; fall back to `asset.meta` for legacy. + const blurhash = + typeof ib.blurhash === "string" + ? ib.blurhash + : typeof meta?.blurhash === "string" + ? meta.blurhash + : null; + const dominantColor = + typeof ib.dominantColor === "string" + ? ib.dominantColor + : typeof meta?.dominantColor === "string" + ? meta.dominantColor + : null; return { type: "image", attrs: { @@ -437,6 +468,8 @@ function convertPTBlock(block: PTBlock): JSONContent | null { provider: asset?.provider, width: ib.width, height: ib.height, + blurhash, + dominantColor, displayWidth: ib.displayWidth, displayHeight: ib.displayHeight, }, @@ -1145,6 +1178,8 @@ interface MediaItemData { storageKey?: string; width?: number; height?: number; + blurhash?: string; + dominantColor?: string; alt?: string; provider?: string; previewUrl?: string; @@ -1224,6 +1259,8 @@ function InlineMediaPicker({ storageKey?: string; width?: number; height?: number; + blurhash?: string; + dominantColor?: string; alt?: string; meta?: Record; }>; @@ -1239,6 +1276,8 @@ function InlineMediaPicker({ storageKey: item.storageKey, width: item.width, height: item.height, + blurhash: item.blurhash, + dominantColor: item.dominantColor, alt: item.alt, provider: activeProvider === "local" ? undefined : activeProvider, previewUrl: item.previewUrl, @@ -1319,6 +1358,8 @@ function InlineMediaPicker({ storageKey: raw.storageKey, width: raw.width || dims.width, height: raw.height || dims.height, + blurhash: raw.blurhash, + dominantColor: raw.dominantColor, alt: raw.alt, }; } else { @@ -1339,6 +1380,8 @@ function InlineMediaPicker({ url: raw.previewUrl || "", width: raw.width || dims.width, height: raw.height || dims.height, + blurhash: raw.blurhash, + dominantColor: raw.dominantColor, alt: raw.alt, provider: activeProvider, previewUrl: raw.previewUrl, @@ -1854,6 +1897,8 @@ export function InlinePortableTextEditor({ provider: { default: null }, width: { default: null }, height: { default: null }, + blurhash: { default: null }, + dominantColor: { default: null }, }; }, }), @@ -1924,6 +1969,8 @@ export function InlinePortableTextEditor({ mediaId: item.id, width: item.width, height: item.height, + blurhash: item.blurhash, + dominantColor: item.dominantColor, }) .run(); setMediaPickerOpen(false); diff --git a/packages/core/src/database/repositories/media.ts b/packages/core/src/database/repositories/media.ts index 59c0ddf4ad..e62850a74f 100644 --- a/packages/core/src/database/repositories/media.ts +++ b/packages/core/src/database/repositories/media.ts @@ -143,7 +143,13 @@ export class MediaRepository { */ async confirmUpload( id: string, - metadata?: { width?: number; height?: number; size?: number }, + metadata?: { + width?: number; + height?: number; + size?: number; + blurhash?: string; + dominantColor?: string; + }, ): Promise { const existing = await this.findById(id); if (!existing) { @@ -156,6 +162,8 @@ export class MediaRepository { if (metadata?.width !== undefined) updates.width = metadata.width; if (metadata?.height !== undefined) updates.height = metadata.height; if (metadata?.size !== undefined) updates.size = metadata.size; + if (metadata?.blurhash !== undefined) updates.blurhash = metadata.blurhash; + if (metadata?.dominantColor !== undefined) updates.dominant_color = metadata.dominantColor; await this.db.updateTable("media").set(updates).where("id", "=", id).execute(); diff --git a/packages/core/src/media/enrich.ts b/packages/core/src/media/enrich.ts new file mode 100644 index 0000000000..8eea2beccb --- /dev/null +++ b/packages/core/src/media/enrich.ts @@ -0,0 +1,78 @@ +/** + * Image Metadata Enrichment + * + * Single seam that derives image dimensions and LQIP placeholders (blurhash, + * dominant color) from raw image bytes. Every server-side media-creation path + * routes through this so records are populated consistently. Pure-JS and + * Workers-safe (image-size reads headers only; generatePlaceholder guards + * decode size). + */ + +import { normalizeMime } from "./mime.js"; +import { generatePlaceholder, readDimensions } from "./placeholder.js"; + +export interface EnrichedImageMetadata { + width?: number; + height?: number; + blurhash?: string; + dominantColor?: string; +} + +/** + * Derive dimensions + LQIP placeholders from image bytes. + * + * - Non-image content types return `{}`. + * - `knownDimensions` (e.g. browser `naturalWidth/Height`) win over `image-size` + * for the *stored record* because the browser applies EXIF orientation; + * `image-size` reports raw header dimensions, which are swapped for + * 90°/270°-rotated JPEGs. They are NOT used for the decode OOM guard — see below. + * - The placeholder OOM guard uses only header dimensions read from the bytes + * actually decoded. Caller-supplied `knownDimensions` are untrusted for the + * guard: a client could claim a tiny size for a huge image to bypass the cap. + * - `placeholder` lets a caller decode a smaller thumbnail for the blurhash to + * avoid OOM on large originals; dimensions still come from `bytes`. + * - Placeholders are jpeg/png only (the generator's supported formats); other + * image types still get dimensions. + */ +export async function enrichImageMetadata( + bytes: Uint8Array, + contentType: string, + opts?: { + knownDimensions?: { width: number; height: number }; + placeholder?: { bytes: Uint8Array; contentType: string }; + }, +): Promise { + const normalizedContentType = normalizeMime(contentType); + if (!normalizedContentType.startsWith("image/")) return {}; + + // Header dimensions are read once from the actual bytes. They feed the + // placeholder OOM guard, which must never trust caller-supplied dimensions: + // `knownDimensions` is decoupled from the buffer, so a client could claim a + // tiny size for a huge image and slip past the decoded-size cap, making the + // decoder allocate an unbounded RGBA buffer and OOM the runtime. Only dims + // read from the buffer that actually gets decoded can bound the decode. + const headerDims = readDimensions(bytes) ?? undefined; + + // Dimensions published on the record prefer the caller's knownDimensions + // (e.g. browser naturalWidth/Height, which apply EXIF orientation) over the + // raw header dims, which are swapped for 90°/270°-rotated JPEGs. + const recordDims = opts?.knownDimensions ?? headerDims; + + // When a smaller thumbnail override is supplied, decode that for the blurhash + // and let generatePlaceholder read the thumbnail's own header for the OOM + // guard (the override buffer is what actually gets decoded). On the common + // no-override path pass the header dims already read from this same buffer. + const override = opts?.placeholder; + const placeholder = await generatePlaceholder( + override ? override.bytes : bytes, + override ? normalizeMime(override.contentType) : normalizedContentType, + override ? undefined : headerDims, + ); + + return { + width: recordDims?.width, + height: recordDims?.height, + blurhash: placeholder?.blurhash, + dominantColor: placeholder?.dominantColor, + }; +} diff --git a/packages/core/src/media/index.ts b/packages/core/src/media/index.ts index 2879a072cb..4a32347df2 100644 --- a/packages/core/src/media/index.ts +++ b/packages/core/src/media/index.ts @@ -27,6 +27,7 @@ export type { export { mediaItemToValue } from "./types.js"; export { normalizeMediaValue } from "./normalize.js"; export { generatePlaceholder, type PlaceholderData } from "./placeholder.js"; +export { enrichImageMetadata, type EnrichedImageMetadata } from "./enrich.js"; // Built-in providers export { localMedia, type LocalMediaConfig } from "./local.js"; diff --git a/packages/core/src/media/local-runtime.ts b/packages/core/src/media/local-runtime.ts index baca6586a8..f3435388bc 100644 --- a/packages/core/src/media/local-runtime.ts +++ b/packages/core/src/media/local-runtime.ts @@ -73,6 +73,8 @@ export const createMediaProvider: CreateMediaProviderFn size: item.size ?? undefined, width: item.width ?? undefined, height: item.height ?? undefined, + blurhash: item.blurhash ?? undefined, + dominantColor: item.dominantColor ?? undefined, alt: item.alt ?? undefined, previewUrl: `/_emdash/api/media/file/${item.storageKey}`, meta: { @@ -97,6 +99,8 @@ export const createMediaProvider: CreateMediaProviderFn size: item.size ?? undefined, width: item.width ?? undefined, height: item.height ?? undefined, + blurhash: item.blurhash ?? undefined, + dominantColor: item.dominantColor ?? undefined, alt: item.alt ?? undefined, previewUrl: `/_emdash/api/media/file/${item.storageKey}`, meta: { @@ -148,6 +152,15 @@ export const createMediaProvider: CreateMediaProviderFn const src = `/_emdash/api/media/file/${storageKey}`; const mimeType = value.mimeType || ""; + // Prefer the first-class fields; fall back to `meta` for legacy snapshots + // stored before LQIP was promoted off the provider-specific `meta` bag. + const blurhash = + value.blurhash ?? + (typeof value.meta?.blurhash === "string" ? value.meta.blurhash : undefined); + const dominantColor = + value.dominantColor ?? + (typeof value.meta?.dominantColor === "string" ? value.meta.dominantColor : undefined); + // Determine embed type based on MIME type if (mimeType.startsWith("image/")) { return { @@ -155,6 +168,8 @@ export const createMediaProvider: CreateMediaProviderFn src, width: value.width, height: value.height, + blurhash, + dominantColor, alt: value.alt, }; } @@ -185,6 +200,8 @@ export const createMediaProvider: CreateMediaProviderFn src, width: value.width, height: value.height, + blurhash, + dominantColor, alt: value.alt, }; }, @@ -221,6 +238,8 @@ export function repoItemToProviderItem(item: { size: item.size ?? undefined, width: item.width ?? undefined, height: item.height ?? undefined, + blurhash: item.blurhash ?? undefined, + dominantColor: item.dominantColor ?? undefined, alt: item.alt ?? undefined, previewUrl: `/_emdash/api/media/file/${item.storageKey}`, meta: { diff --git a/packages/core/src/media/normalize.ts b/packages/core/src/media/normalize.ts index c1c491daa4..945dc66978 100644 --- a/packages/core/src/media/normalize.ts +++ b/packages/core/src/media/normalize.ts @@ -60,7 +60,14 @@ export async function normalizeMediaValue( const needsDimensions = result.width == null || result.height == null; const needsStorageKey = provider === "local" && !result.meta?.storageKey; const needsFileInfo = !result.mimeType || !result.filename; - const needsLookup = needsDimensions || needsStorageKey || needsFileInfo; + // LQIP placeholders are immutable facts of the bytes: if an image record is + // missing them, the provider may have gained them since (e.g. content saved + // before LQIP backfill ran, or the row's blurhash was populated later). Pull + // them on every image lookup so the LQIP backfill in mergeProviderData runs. + const needsLqip = + (result.mimeType ?? "").startsWith("image/") && + (result.blurhash == null || result.dominantColor == null); + const needsLookup = needsDimensions || needsStorageKey || needsFileInfo || needsLqip; if (!needsLookup || !id) return result; @@ -138,6 +145,8 @@ async function resolveInternalUrl( mimeType: item.mimeType, width: item.width, height: item.height, + blurhash: item.blurhash, + dominantColor: item.dominantColor, alt: item.alt, meta: item.meta, }; @@ -167,6 +176,8 @@ async function resolveLocalId( mimeType: item.mimeType, width: item.width, height: item.height, + blurhash: item.blurhash, + dominantColor: item.dominantColor, alt: item.alt, meta: item.meta, }; @@ -183,6 +194,12 @@ function mergeProviderData(existing: MediaValue, item: MediaProviderItem): Media if (result.width == null && item.width != null) result.width = item.width; if (result.height == null && item.height != null) result.height = item.height; + // Fill missing LQIP placeholders (immutable facts of the bytes; caller wins) + if (result.blurhash == null && item.blurhash != null) result.blurhash = item.blurhash; + if (result.dominantColor == null && item.dominantColor != null) { + result.dominantColor = item.dominantColor; + } + // Fill missing file info if (!result.filename && item.filename) result.filename = item.filename; if (!result.mimeType && item.mimeType) result.mimeType = item.mimeType; @@ -217,6 +234,8 @@ function recordToMediaValue(obj: Record): MediaValue { if (typeof obj.mimeType === "string") result.mimeType = obj.mimeType; if (typeof obj.width === "number") result.width = obj.width; if (typeof obj.height === "number") result.height = obj.height; + if (typeof obj.blurhash === "string") result.blurhash = obj.blurhash; + if (typeof obj.dominantColor === "string") result.dominantColor = obj.dominantColor; if (typeof obj.alt === "string") result.alt = obj.alt; if (isRecord(obj.meta)) result.meta = obj.meta; return result; diff --git a/packages/core/src/media/placeholder.ts b/packages/core/src/media/placeholder.ts index 51393c1ded..ac7a232fe4 100644 --- a/packages/core/src/media/placeholder.ts +++ b/packages/core/src/media/placeholder.ts @@ -9,6 +9,8 @@ import { encode } from "blurhash"; import { imageSize } from "image-size"; +import { normalizeMime } from "./mime.js"; + export interface PlaceholderData { blurhash: string; dominantColor: string; @@ -85,8 +87,12 @@ function extractDominantColor(data: Uint8Array, width: number, height: number): /** * Read image dimensions from headers without decoding pixel data. + * Returns null when the header cannot be parsed. + * + * Shared by every caller that needs pixel dimensions so the header is parsed + * once per buffer, not re-read inside generatePlaceholder. */ -function getImageDimensions(buffer: Uint8Array): { width: number; height: number } | null { +export function readDimensions(buffer: Uint8Array): { width: number; height: number } | null { try { const result = imageSize(buffer); if (result.width != null && result.height != null) { @@ -102,23 +108,37 @@ function getImageDimensions(buffer: Uint8Array): { width: number; height: number * Generate blurhash and dominant color from an image buffer. * Returns null for non-image MIME types or on failure. * - * @param dimensions - Optional pre-known dimensions. Used as a fallback when - * image-size cannot parse the buffer (e.g. truncated headers). When the - * decoded size (width * height * 4) exceeds MAX_DECODED_BYTES, placeholder - * generation is skipped to avoid OOM on memory-constrained runtimes. + * @param dimensions - Optional pre-known dimensions. When present they are + * trusted verbatim (the caller has typically already read them via + * readDimensions); otherwise dimensions are read from this buffer's header. + * Generation is skipped (returns null) when no dimensions are available at + * all, or when the decoded size (width * height * 4) exceeds + * MAX_DECODED_BYTES — both guards avoid OOM from unbounded decodes on + * memory-constrained runtimes. */ export async function generatePlaceholder( buffer: Uint8Array, mimeType: string, dimensions?: { width: number; height: number }, ): Promise { - const format = SUPPORTED_TYPES[mimeType]; + const format = SUPPORTED_TYPES[normalizeMime(mimeType)]; if (!format) return null; try { - // Safety net: skip decode if the image would exceed the memory budget - const dims = getImageDimensions(buffer) ?? dimensions; - if (dims && dims.width * dims.height * 4 > MAX_DECODED_BYTES) { + // Trust caller-supplied dimensions when present (the caller has usually + // already read them via readDimensions); otherwise read them from this + // buffer. The header is parsed at most once per call. + const dims = dimensions ?? readDimensions(buffer); + + // Safety net: the decoders allocate the full RGBA buffer with no internal + // cap, so refuse to decode unless we can bound the output size. When we + // have no parseable header AND no known dimensions, the decoded size is + // unbounded — a crafted/truncated PNG whose header image-size can't read + // could still be decodable by upng-js, so we must bail to avoid OOM on + // memory-constrained runtimes. LQIP is progressive enhancement; missing + // it is preferable to crashing the request. + if (!dims) return null; + if (dims.width * dims.height * 4 > MAX_DECODED_BYTES) { return null; } diff --git a/packages/core/src/media/types.ts b/packages/core/src/media/types.ts index 3139f97333..e9b8bc4800 100644 --- a/packages/core/src/media/types.ts +++ b/packages/core/src/media/types.ts @@ -85,6 +85,10 @@ export interface MediaProviderItem { /** Dimensions (for images/video) */ width?: number; height?: number; + /** LQIP blurhash placeholder (images only) */ + blurhash?: string; + /** LQIP dominant-color placeholder, as a CSS color (images only) */ + dominantColor?: string; /** Accessibility text */ alt?: string; /** Preview URL for admin UI thumbnail */ @@ -126,6 +130,10 @@ export interface ImageEmbed { sizes?: string; width?: number; height?: number; + /** LQIP blurhash placeholder for rendering before the image loads */ + blurhash?: string; + /** LQIP dominant-color placeholder, as a CSS color */ + dominantColor?: string; alt?: string; /** Base URL without transforms, for responsive image generation */ cdnBaseUrl?: string; @@ -256,6 +264,10 @@ export interface MediaValue { mimeType?: string; width?: number; height?: number; + /** Cached LQIP blurhash placeholder (images only) */ + blurhash?: string; + /** Cached LQIP dominant-color placeholder, as a CSS color (images only) */ + dominantColor?: string; alt?: string; /** Provider-specific data needed for embedding */ @@ -273,6 +285,8 @@ export function mediaItemToValue(providerId: string, item: MediaProviderItem): M mimeType: item.mimeType, width: item.width, height: item.height, + blurhash: item.blurhash, + dominantColor: item.dominantColor, alt: item.alt, meta: item.meta, }; diff --git a/packages/core/src/plugins/context.ts b/packages/core/src/plugins/context.ts index 533a87bffa..c7a9b53396 100644 --- a/packages/core/src/plugins/context.ts +++ b/packages/core/src/plugins/context.ts @@ -21,6 +21,7 @@ import { SsrfError, stripCredentialHeaders, } from "../import/ssrf.js"; +import { enrichImageMetadata } from "../media/enrich.js"; import { invalidateSiteSettingsCache } from "../settings/index.js"; import type { Storage } from "../storage/types.js"; import { CronAccessImpl } from "./cron.js"; @@ -508,6 +509,9 @@ export function createMediaAccessWithWrite( contentType, }); + // Derive dimensions + LQIP placeholders (no-op for non-images). + const enriched = await enrichImageMetadata(new Uint8Array(bytes), contentType); + // Create DB record — clean up storage on failure let media; try { @@ -517,6 +521,10 @@ export function createMediaAccessWithWrite( size: bytes.byteLength, storageKey, status: "ready", + width: enriched.width, + height: enriched.height, + blurhash: enriched.blurhash, + dominantColor: enriched.dominantColor, }); } catch (error) { try { diff --git a/packages/core/tests/integration/astro/media-confirm-placeholder.test.ts b/packages/core/tests/integration/astro/media-confirm-placeholder.test.ts new file mode 100644 index 0000000000..bcfc9d345c --- /dev/null +++ b/packages/core/tests/integration/astro/media-confirm-placeholder.test.ts @@ -0,0 +1,132 @@ +import type { APIContext } from "astro"; +import type { Kysely } from "kysely"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { POST as postConfirm } from "../../../src/astro/routes/api/media/[id]/confirm.js"; +import { MediaRepository } from "../../../src/database/repositories/media.js"; +import type { Database } from "../../../src/database/types.js"; +import { JPEG_4x4 } from "../../utils/image-fixtures.js"; +import { setupTestDatabase, teardownTestDatabase } from "../../utils/test-db.js"; + +/** Storage stub matching the real interface: download returns a ReadableStream. */ +function storageWith(bytes: Uint8Array) { + return { + async exists() { + return true; + }, + async download() { + return { + body: new Response(bytes).body as ReadableStream, + contentType: "image/jpeg", + size: bytes.byteLength, + }; + }, + }; +} + +/** Storage stub whose download is spyable (to assert read-back never happens). */ +function spyableStorage(bytes: Uint8Array) { + const download = vi.fn(async () => ({ + body: new Response(bytes).body as ReadableStream, + contentType: "image/jpeg", + size: bytes.byteLength, + })); + return { + exists: vi.fn(async () => true), + download, + }; +} + +function buildContext(opts: { + db: Kysely; + id: string; + storage: unknown; + body: Record; +}): APIContext { + const request = new Request(`http://localhost/_emdash/api/media/${opts.id}/confirm`, { + method: "POST", + headers: { "Content-Type": "application/json", "X-EmDash-Request": "1" }, + body: JSON.stringify(opts.body), + }); + return { + params: { id: opts.id }, + url: new URL(request.url), + request, + locals: { + emdash: { db: opts.db, storage: opts.storage }, + user: { id: "user-1", email: "t@example.com", name: "T", role: 50 as const }, + }, + // eslint-disable-next-line typescript/no-unsafe-type-assertion -- minimal stub for tests + } as unknown as APIContext; +} + +describe("POST /media/:id/confirm — placeholder read-back", () => { + let db: Kysely; + + beforeEach(async () => { + db = await setupTestDatabase(); + }); + + afterEach(async () => { + await teardownTestDatabase(db); + }); + + it("computes blurhash and dominantColor from the stored image on confirm", async () => { + const repo = new MediaRepository(db); + const pending = await repo.createPending({ + filename: "photo.jpg", + mimeType: "image/jpeg", + storageKey: "photo.jpg", + authorId: "user-1", + }); + + const res = await postConfirm( + buildContext({ + db, + id: pending.id, + storage: storageWith(JPEG_4x4), + body: { size: JPEG_4x4.byteLength, width: 4, height: 4 }, + }), + ); + + expect(res.status).toBe(200); + const row = await repo.findById(pending.id); + expect(row?.status).toBe("ready"); + expect(row?.width).toBe(4); + expect(row?.blurhash).toBeTruthy(); + expect(row?.dominantColor).toMatch(/^rgb\(/); + }); + + it("skips placeholder read-back for oversized images (OOM guard) but still confirms", async () => { + const repo = new MediaRepository(db); + const pending = await repo.createPending({ + filename: "huge.jpg", + mimeType: "image/jpeg", + storageKey: "huge.jpg", + authorId: "user-1", + }); + const storage = spyableStorage(JPEG_4x4); + + // Confirm claims a size far above the download cap. The signed-URL flow + // exists so large files bypass server buffering; confirm must not re-read + // such an object into memory just to compute a blurhash. + const res = await postConfirm( + buildContext({ + db, + id: pending.id, + storage, + body: { size: 64 * 1024 * 1024, width: 4000, height: 3000 }, + }), + ); + + expect(res.status).toBe(200); + expect(storage.download).not.toHaveBeenCalled(); + const row = await repo.findById(pending.id); + expect(row?.status).toBe("ready"); + // Client-supplied dimensions are still recorded even when LQIP is skipped. + expect(row?.width).toBe(4000); + expect(row?.height).toBe(3000); + expect(row?.blurhash).toBeNull(); + expect(row?.dominantColor).toBeNull(); + }); +}); diff --git a/packages/core/tests/integration/astro/media-upload-placeholder.test.ts b/packages/core/tests/integration/astro/media-upload-placeholder.test.ts new file mode 100644 index 0000000000..e5c99e4c86 --- /dev/null +++ b/packages/core/tests/integration/astro/media-upload-placeholder.test.ts @@ -0,0 +1,113 @@ +import type { APIContext } from "astro"; +import type { Kysely } from "kysely"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { POST as postMedia } from "../../../src/astro/routes/api/media.js"; +import type { Database } from "../../../src/database/types.js"; +import { JPEG_4x4 } from "../../utils/image-fixtures.js"; +import { setupTestDatabase, teardownTestDatabase } from "../../utils/test-db.js"; + +interface CapturedInput { + width?: number; + height?: number; + blurhash?: string; + dominantColor?: string; +} + +function buildContext(opts: { + db: Kysely; + file: File; + captured: { value?: CapturedInput }; + width?: number; + height?: number; +}): APIContext { + const formData = new FormData(); + formData.append("file", opts.file); + if (opts.width != null) formData.append("width", String(opts.width)); + if (opts.height != null) formData.append("height", String(opts.height)); + const request = new Request("http://localhost/_emdash/api/media", { + method: "POST", + headers: { "X-EmDash-Request": "1" }, + body: formData, + }); + return { + params: {}, + url: new URL(request.url), + request, + locals: { + emdash: { + db: opts.db, + config: {}, + storage: { + async upload(o: { key: string }) { + return { key: o.key, url: `/m/${o.key}`, size: 0 }; + }, + }, + handleMediaCreate: async (input: CapturedInput & Record) => { + opts.captured.value = input; + return { + success: true as const, + data: { item: { id: "t", storageKey: "k", ...input } }, + }; + }, + }, + user: { id: "user-1", email: "t@example.com", name: "T", role: 50 as const }, + }, + // eslint-disable-next-line typescript/no-unsafe-type-assertion -- minimal stub for tests + } as unknown as APIContext; +} + +describe("POST /media — server-side placeholder + dimensions", () => { + let db: Kysely; + + beforeEach(async () => { + db = await setupTestDatabase(); + }); + + afterEach(async () => { + await teardownTestDatabase(db); + }); + + it("derives dimensions and blurhash from the uploaded image when the client sends none", async () => { + const captured: { value?: CapturedInput } = {}; + const file = new File([JPEG_4x4], "photo.jpg", { type: "image/jpeg" }); + + const res = await postMedia(buildContext({ db, file, captured })); + + expect(res.status).toBe(201); + expect(captured.value?.width).toBe(4); + expect(captured.value?.height).toBe(4); + expect(captured.value?.blurhash).toBeTruthy(); + expect(captured.value?.dominantColor).toMatch(/^rgb\(/); + }); + + it("preserves client-sent width/height for non-image uploads and skips placeholder", async () => { + const captured: { value?: CapturedInput } = {}; + const file = new File([new Uint8Array([1, 2, 3, 4])], "doc.pdf", { type: "application/pdf" }); + + const res = await postMedia(buildContext({ db, file, captured, width: 640, height: 480 })); + + expect(res.status).toBe(201); + expect(captured.value?.width).toBe(640); + expect(captured.value?.height).toBe(480); + expect(captured.value?.blurhash).toBeUndefined(); + expect(captured.value?.dominantColor).toBeUndefined(); + }); + + it("prefers client-sent width/height over server-derived dims (EXIF orientation safety)", async () => { + // Browser naturalWidth/Height apply EXIF orientation; image-size reports raw + // header dims (swapped for 90°/270° JPEGs). REST must honor client dims — + // matching confirm.ts — not the server header dims. + const captured: { value?: CapturedInput } = {}; + const file = new File([JPEG_4x4], "photo.jpg", { type: "image/jpeg" }); + + const res = await postMedia(buildContext({ db, file, captured, width: 999, height: 999 })); + + expect(res.status).toBe(201); + // Client dimensions win even though the server read 4×4 from the header. + expect(captured.value?.width).toBe(999); + expect(captured.value?.height).toBe(999); + // A blurhash is still generated from the uploaded bytes. + expect(captured.value?.blurhash).toBeTruthy(); + }); +}); diff --git a/packages/core/tests/integration/runtime/plugin-media-enrich.test.ts b/packages/core/tests/integration/runtime/plugin-media-enrich.test.ts new file mode 100644 index 0000000000..682bd89046 --- /dev/null +++ b/packages/core/tests/integration/runtime/plugin-media-enrich.test.ts @@ -0,0 +1,85 @@ +import type { Kysely } from "kysely"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { MediaRepository } from "../../../src/database/repositories/media.js"; +import type { Database } from "../../../src/database/types.js"; +import { createMediaAccessWithWrite } from "../../../src/plugins/context.js"; +import type { Storage } from "../../../src/storage/types.js"; +import { JPEG_4x4 } from "../../utils/image-fixtures.js"; +import { setupTestDatabase, teardownTestDatabase } from "../../utils/test-db.js"; + +function fakeStorage(): Storage { + const store = new Map(); + return { + async upload(o) { + const b = o.body instanceof Uint8Array ? o.body : new Uint8Array(o.body as ArrayBuffer); + store.set(o.key, b); + return { key: o.key, url: `/m/${o.key}`, size: b.byteLength }; + }, + async download(key) { + const b = store.get(key) ?? new Uint8Array(); + return { + body: new Response(b).body as ReadableStream, + contentType: "application/octet-stream", + size: b.byteLength, + }; + }, + async delete(key) { + store.delete(key); + }, + async exists(key) { + return store.has(key); + }, + async list() { + return { files: [] }; + }, + async getSignedUploadUrl(o) { + return { + url: `/s/${o.key}`, + method: "PUT", + headers: {}, + expiresAt: new Date().toISOString(), + }; + }, + getPublicUrl(key) { + return `/m/${key}`; + }, + }; +} + +describe("plugin ctx.media.upload — metadata enrichment", () => { + let db: Kysely; + + beforeEach(async () => { + db = await setupTestDatabase(); + }); + + afterEach(async () => { + await teardownTestDatabase(db); + }); + + it("populates width, height, blurhash and dominantColor for an image upload", async () => { + const media = createMediaAccessWithWrite(db, undefined, fakeStorage()); + const ab = JPEG_4x4.slice().buffer; // clean ArrayBuffer copy + const result = await media.upload("derived.jpg", "image/jpeg", ab); + + const row = await new MediaRepository(db).findById(result.mediaId); + expect(row?.width).toBe(4); + expect(row?.height).toBe(4); + expect(row?.blurhash).toBeTruthy(); + expect(row?.dominantColor).toMatch(/^rgb\(/); + }); + + it("leaves metadata null for a non-image upload without throwing", async () => { + const media = createMediaAccessWithWrite(db, undefined, fakeStorage()); + const result = await media.upload( + "data.bin", + "application/octet-stream", + new Uint8Array([1, 2, 3, 4]).buffer, + ); + + const row = await new MediaRepository(db).findById(result.mediaId); + expect(row?.width).toBeNull(); + expect(row?.blurhash).toBeNull(); + }); +}); diff --git a/packages/core/tests/integration/wordpress-import/media-enrich.test.ts b/packages/core/tests/integration/wordpress-import/media-enrich.test.ts new file mode 100644 index 0000000000..5a112d4429 --- /dev/null +++ b/packages/core/tests/integration/wordpress-import/media-enrich.test.ts @@ -0,0 +1,61 @@ +import type { Kysely } from "kysely"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const { JPEG } = vi.hoisted(() => ({ + JPEG: new Uint8Array( + Buffer.from( + "/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAMCAgICAgMCAgIDAwMDBAYEBAQEBAgGBgUGCQgKCgkICQkKDA8MCgsOCwkJDRENDg8QEBEQCgwSExIQEw8QEBD/2wBDAQMDAwQDBAgEBAgQCwkLEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBD/wAARCAAEAAQDAREAAhEBAxEB/8QAFAABAAAAAAAAAAAAAAAAAAAACP/EABQQAQAAAAAAAAAAAAAAAAAAAAD/xAAVAQEBAAAAAAAAAAAAAAAAAAAHCf/EABQRAQAAAAAAAAAAAAAAAAAAAAD/2gAMAwEAAhEDEQA/ADoDFU3/2Q==", + "base64", + ), + ), +})); + +vi.mock("#import/ssrf.js", () => ({ + validateExternalUrl: () => {}, + SsrfError: class SsrfError extends Error {}, + ssrfSafeFetch: async () => + new Response(JPEG, { status: 200, headers: { "content-type": "image/jpeg" } }), +})); + +import { importMediaWithProgress } from "../../../src/astro/routes/api/import/wordpress/media.js"; +import { MediaRepository } from "../../../src/database/repositories/media.js"; +import type { Database } from "../../../src/database/types.js"; +import type { Storage } from "../../../src/storage/types.js"; +import { setupTestDatabase, teardownTestDatabase } from "../../utils/test-db.js"; + +function fakeStorage(): Pick { + return { + async upload(o) { + const b = o.body instanceof Uint8Array ? o.body : new Uint8Array(o.body as ArrayBuffer); + return { key: o.key, url: `/m/${o.key}`, size: b.byteLength }; + }, + }; +} + +describe("WordPress import — media enrichment", () => { + let db: Kysely; + + beforeEach(async () => { + db = await setupTestDatabase(); + }); + + afterEach(async () => { + await teardownTestDatabase(db); + vi.restoreAllMocks(); + }); + + it("enriches imported images with dimensions and placeholders", async () => { + const result = await importMediaWithProgress( + [{ id: 1, url: "https://cdn.example.com/p.jpg", filename: "p.jpg", mimeType: "image/jpeg" }], + db, + fakeStorage() as Storage, + () => {}, + ); + + expect(result.imported).toHaveLength(1); + const row = await new MediaRepository(db).findById(result.imported[0]!.mediaId); + expect(row?.width).toBe(4); + expect(row?.height).toBe(4); + expect(row?.blurhash).toBeTruthy(); + }); +}); diff --git a/packages/core/tests/unit/components/inline-portable-text-image.test.ts b/packages/core/tests/unit/components/inline-portable-text-image.test.ts new file mode 100644 index 0000000000..d5a92dc0f6 --- /dev/null +++ b/packages/core/tests/unit/components/inline-portable-text-image.test.ts @@ -0,0 +1,119 @@ +/** + * Inline editor image LQIP round-trip tests. + * + * Verifies blurhash/dominantColor survive the Portable Text ↔ ProseMirror + * conversion the inline (visual-editing) editor exercises, 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 { + _pmToPortableText as pmToPortableText, + _portableTextToPM as portableTextToPM, +} from "../../../src/components/InlinePortableTextEditor.js"; + +describe("Image LQIP round-trip (inline editor seam)", () => { + it("preserves blurhash and dominantColor through PT → PM → PT (legacy asset.meta input)", () => { + const imageBlock = { + _type: "image", + _key: "img001", + asset: { + _ref: "01ABC", + url: "/_emdash/api/media/file/01ABC.jpg", + meta: { + blurhash: "LEHV6nWB2yk8pyo0adR*.7kCMdnj", + dominantColor: "#aabbcc", + }, + }, + alt: "A photo", + width: 1200, + height: 800, + }; + + const pm = portableTextToPM([imageBlock]); + const node = pm.content?.[0] as { + type: string; + attrs?: { blurhash?: string; dominantColor?: string }; + }; + + expect(node.type).toBe("image"); + expect(node.attrs?.blurhash).toBe("LEHV6nWB2yk8pyo0adR*.7kCMdnj"); + expect(node.attrs?.dominantColor).toBe("#aabbcc"); + + const pt = pmToPortableText(pm); + const restored = pt[0] as { + _type: string; + asset?: { meta?: Record }; + blurhash?: string; + dominantColor?: string; + }; + + // Promoted to first-class fields on save; asset.meta no longer carries them. + expect(restored._type).toBe("image"); + 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 imageBlock = { + _type: "image", + _key: "img001b", + asset: { _ref: "01ABC2", url: "/_emdash/api/media/file/01ABC2.jpg" }, + alt: "A photo", + width: 1200, + height: 800, + blurhash: "L6PZfSi_.AyE_3t7t7R**0o#DgR4", + dominantColor: "#112233", + }; + + const pm = portableTextToPM([imageBlock]); + const node = pm.content?.[0] as { + attrs?: { blurhash?: string; dominantColor?: string }; + }; + expect(node.attrs?.blurhash).toBe("L6PZfSi_.AyE_3t7t7R**0o#DgR4"); + expect(node.attrs?.dominantColor).toBe("#112233"); + + const pt = pmToPortableText(pm); + const restored = pt[0] as { + blurhash?: string; + dominantColor?: string; + asset?: { meta?: Record }; + }; + expect(restored.blurhash).toBe("L6PZfSi_.AyE_3t7t7R**0o#DgR4"); + expect(restored.dominantColor).toBe("#112233"); + expect(restored.asset?.meta?.blurhash).toBeUndefined(); + }); + + it("omits LQIP entirely when none is present", () => { + const imageBlock = { + _type: "image", + _key: "img002", + asset: { _ref: "01XYZ", url: "/_emdash/api/media/file/01XYZ.jpg" }, + alt: "No placeholder", + width: 640, + height: 480, + }; + + const pm = portableTextToPM([imageBlock]); + const node = pm.content?.[0] as { + attrs?: { blurhash?: string | null; dominantColor?: string | null }; + }; + expect(node.attrs?.blurhash ?? null).toBeNull(); + expect(node.attrs?.dominantColor ?? null).toBeNull(); + + const pt = pmToPortableText(pm); + const restored = pt[0] as { + asset?: { meta?: Record }; + blurhash?: string; + dominantColor?: string; + }; + expect(restored.asset?.meta).toBeUndefined(); + expect(restored.blurhash).toBeUndefined(); + expect(restored.dominantColor).toBeUndefined(); + }); +}); diff --git a/packages/core/tests/unit/database/repositories/media-confirm.test.ts b/packages/core/tests/unit/database/repositories/media-confirm.test.ts new file mode 100644 index 0000000000..dcd95cc8b6 --- /dev/null +++ b/packages/core/tests/unit/database/repositories/media-confirm.test.ts @@ -0,0 +1,41 @@ +import type { Kysely } from "kysely"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { MediaRepository } from "../../../../src/database/repositories/media.js"; +import type { Database } from "../../../../src/database/types.js"; +import { setupTestDatabase, teardownTestDatabase } from "../../../utils/test-db.js"; + +describe("MediaRepository.confirmUpload", () => { + let db: Kysely; + let repo: MediaRepository; + + beforeEach(async () => { + db = await setupTestDatabase(); + repo = new MediaRepository(db); + }); + + afterEach(async () => { + await teardownTestDatabase(db); + }); + + it("persists blurhash and dominantColor when confirming a pending upload", async () => { + const pending = await repo.createPending({ + filename: "x.jpg", + mimeType: "image/jpeg", + storageKey: "x.jpg", + }); + + const confirmed = await repo.confirmUpload(pending.id, { + width: 4, + height: 4, + size: 100, + blurhash: "LEHV6nWB2yk8", + dominantColor: "rgb(255,0,0)", + }); + + expect(confirmed?.status).toBe("ready"); + expect(confirmed?.width).toBe(4); + expect(confirmed?.blurhash).toBe("LEHV6nWB2yk8"); + expect(confirmed?.dominantColor).toBe("rgb(255,0,0)"); + }); +}); diff --git a/packages/core/tests/unit/media/enrich.test.ts b/packages/core/tests/unit/media/enrich.test.ts new file mode 100644 index 0000000000..95f0397c25 --- /dev/null +++ b/packages/core/tests/unit/media/enrich.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from "vitest"; + +import { enrichImageMetadata } from "../../../src/media/enrich.js"; +import { GIF_1x1, JPEG_4x4, PNG_4x4 } from "../../utils/image-fixtures.js"; + +describe("enrichImageMetadata", () => { + it("returns dimensions, blurhash and dominantColor for a JPEG", async () => { + const result = await enrichImageMetadata(JPEG_4x4, "image/jpeg"); + expect(result.width).toBe(4); + expect(result.height).toBe(4); + expect(result.blurhash).toBeTruthy(); + expect(result.dominantColor).toMatch(/^rgb\(/); + }); + + it("returns dimensions and a blurhash for a PNG", async () => { + const result = await enrichImageMetadata(PNG_4x4, "image/png"); + expect(result.width).toBe(4); + expect(result.height).toBe(4); + expect(result.blurhash).toBeTruthy(); + }); + + it("returns an empty object for non-image content types", async () => { + const result = await enrichImageMetadata(new Uint8Array([1, 2, 3]), "application/pdf"); + expect(result).toEqual({}); + }); + + it("prefers knownDimensions over header-derived dimensions (EXIF orientation safety)", async () => { + const result = await enrichImageMetadata(JPEG_4x4, "image/jpeg", { + knownDimensions: { width: 5, height: 9 }, + }); + expect(result.width).toBe(5); + expect(result.height).toBe(9); + }); + + it("decodes the placeholder override while keeping dimensions from the main bytes", async () => { + const result = await enrichImageMetadata(JPEG_4x4, "image/jpeg", { + placeholder: { bytes: PNG_4x4, contentType: "image/png" }, + }); + expect(result.width).toBe(4); // from JPEG_4x4 header + expect(result.blurhash).toBeTruthy(); // decoded from the PNG override + }); + + it("returns dimensions but no blurhash for a GIF (placeholder format unsupported)", async () => { + const result = await enrichImageMetadata(GIF_1x1, "image/gif"); + expect(result.width).toBe(1); + expect(result.height).toBe(1); + expect(result.blurhash).toBeUndefined(); + }); + + it("normalizes uppercase content types before placeholder generation (image/JPEG)", async () => { + const result = await enrichImageMetadata(JPEG_4x4, "image/JPEG"); + expect(result.width).toBe(4); + expect(result.height).toBe(4); + expect(result.blurhash).toBeTruthy(); + expect(result.dominantColor).toMatch(/^rgb\(/); + }); + + it("normalizes parameter-suffixed content types (image/jpeg; charset=binary)", async () => { + const result = await enrichImageMetadata(JPEG_4x4, "image/jpeg; charset=binary"); + expect(result.blurhash).toBeTruthy(); + }); + + it("treats image/JPG as jpeg", async () => { + const result = await enrichImageMetadata(JPEG_4x4, "image/JPG"); + expect(result.blurhash).toBeTruthy(); + }); + + it("guards the decode with header dimensions, not client knownDimensions (OOM bypass)", async () => { + // A real JPEG whose header declares 3000×3000 (3000²×4 = 36 MB RGBA, over + // the 32 MB decode cap) but is tiny on the wire (solid color). A malicious + // client claims a 1×1 size to slip past the guard so the decoder allocates + // the full RGBA buffer and OOMs the runtime. + const { encode } = await import("jpeg-js"); + const side = 3000; + const raw = { data: Buffer.alloc(side * side * 4, 0xff), width: side, height: side }; + const bigJpeg = new Uint8Array(encode(raw, 50).data); + + const result = await enrichImageMetadata(bigJpeg, "image/jpeg", { + knownDimensions: { width: 1, height: 1 }, + }); + + // The guard reads the real header dims and skips the oversized decode. + expect(result.blurhash).toBeUndefined(); + expect(result.dominantColor).toBeUndefined(); + // Client dims are still trusted for the stored record (EXIF-orientation fix). + expect(result.width).toBe(1); + expect(result.height).toBe(1); + }); +}); diff --git a/packages/core/tests/unit/media/media-value.test.ts b/packages/core/tests/unit/media/media-value.test.ts new file mode 100644 index 0000000000..67a9673129 --- /dev/null +++ b/packages/core/tests/unit/media/media-value.test.ts @@ -0,0 +1,78 @@ +import { describe, it, expect } from "vitest"; + +import { createMediaProvider } from "../../../src/media/local-runtime.js"; +import { mediaItemToValue } from "../../../src/media/types.js"; +import type { MediaProviderItem, MediaValue } from "../../../src/media/types.js"; + +describe("mediaItemToValue", () => { + it("copies blurhash and dominantColor onto the MediaValue", () => { + const item: MediaProviderItem = { + id: "01ABC", + filename: "photo.jpg", + mimeType: "image/jpeg", + width: 1200, + height: 800, + blurhash: "LEHV6nWB2yk8pyo0adR*.7kCMdnj", + dominantColor: "#aabbcc", + }; + + const value = mediaItemToValue("local", item); + + expect(value).toMatchObject({ + provider: "local", + id: "01ABC", + width: 1200, + height: 800, + blurhash: "LEHV6nWB2yk8pyo0adR*.7kCMdnj", + dominantColor: "#aabbcc", + }); + }); +}); + +describe("local provider getEmbed", () => { + // getEmbed never touches the database, so a stub db is enough to construct + // the provider. + const provider = createMediaProvider({ db: {} as never }); + + it("surfaces top-level blurhash and dominantColor on the image embed", () => { + const value: MediaValue = { + provider: "local", + id: "01ABC", + mimeType: "image/jpeg", + width: 1200, + height: 800, + blurhash: "LEHV6nWB2yk8pyo0adR*.7kCMdnj", + dominantColor: "#aabbcc", + meta: { storageKey: "01ABC.jpg" }, + }; + + const embed = provider.getEmbed(value); + + expect(embed).toMatchObject({ + type: "image", + blurhash: "LEHV6nWB2yk8pyo0adR*.7kCMdnj", + dominantColor: "#aabbcc", + }); + }); + + it("falls back to meta.blurhash for legacy MediaValue snapshots", () => { + const value: MediaValue = { + provider: "local", + id: "01ABC", + mimeType: "image/jpeg", + meta: { + storageKey: "01ABC.jpg", + blurhash: "LEHV6nWB2yk8pyo0adR*.7kCMdnj", + dominantColor: "#aabbcc", + }, + }; + + const embed = provider.getEmbed(value); + + expect(embed).toMatchObject({ + type: "image", + blurhash: "LEHV6nWB2yk8pyo0adR*.7kCMdnj", + dominantColor: "#aabbcc", + }); + }); +}); diff --git a/packages/core/tests/unit/media/normalize.test.ts b/packages/core/tests/unit/media/normalize.test.ts index 5c55a57e67..ceaa438c2c 100644 --- a/packages/core/tests/unit/media/normalize.test.ts +++ b/packages/core/tests/unit/media/normalize.test.ts @@ -246,7 +246,7 @@ describe("normalizeMediaValue", () => { }); }); - it("does not call provider when dimensions already present", async () => { + it("still calls the provider to backfill LQIP for images even when dimensions are present", async () => { const cfImages = mockProvider(null); const value = { @@ -263,10 +263,48 @@ describe("normalizeMediaValue", () => { const result = await normalizeMediaValue(value, getProvider({ "cloudflare-images": cfImages })); - expect(cfImages.get).not.toHaveBeenCalled(); + // blurhash/dominantColor are missing → provider is consulted to backfill + // them (e.g. records saved before LQIP, or rows that gained a blurhash + // later). Provider.get returns null here, so the value is unchanged. + expect(cfImages.get).toHaveBeenCalledWith("cf-abc123"); expect(result).toEqual(value); }); + it("backfills blurhash and dominantColor from the provider when only LQIP is missing", async () => { + const providerItem: MediaProviderItem = { + id: "01ABC", + filename: "photo.jpg", + mimeType: "image/jpeg", + width: 1200, + height: 800, + blurhash: "L6PZfSi_.AyE_3t7t7R**0o#DgR4", + dominantColor: "#112233", + meta: { storageKey: "01ABC.jpg" }, + }; + const local = mockProvider(providerItem); + + // Fully populated article row except for LQIP — gain from finding #3: + // the provider is now consulted so the blurhash backfill runs. + const result = await normalizeMediaValue( + { + provider: "local", + id: "01ABC", + width: 1200, + height: 800, + filename: "photo.jpg", + mimeType: "image/jpeg", + meta: { storageKey: "01ABC.jpg" }, + }, + getProvider({ local }), + ); + + expect(local.get).toHaveBeenCalledWith("01ABC"); + expect(result).toMatchObject({ + blurhash: "L6PZfSi_.AyE_3t7t7R**0o#DgR4", + dominantColor: "#112233", + }); + }); + it("preserves caller alt over provider alt", async () => { const providerItem: MediaProviderItem = { id: "01ABC", @@ -428,6 +466,89 @@ describe("normalizeMediaValue", () => { expect(local.get).toHaveBeenCalledWith("01ABC"); }); + it("copies blurhash and dominantColor from local provider to top-level", async () => { + const providerItem: MediaProviderItem = { + id: "01ABC", + filename: "photo.jpg", + mimeType: "image/jpeg", + width: 1200, + height: 800, + blurhash: "LEHV6nWB2yk8pyo0adR*.7kCMdnj", + dominantColor: "#aabbcc", + meta: { storageKey: "01ABC.jpg" }, + }; + const local = mockProvider(providerItem); + + // Missing filename/mimeType triggers the provider lookup. + const result = await normalizeMediaValue( + { + provider: "local", + id: "01ABC", + width: 1200, + height: 800, + meta: { storageKey: "01ABC.jpg" }, + }, + getProvider({ local }), + ); + + expect(result).toMatchObject({ + provider: "local", + id: "01ABC", + blurhash: "LEHV6nWB2yk8pyo0adR*.7kCMdnj", + dominantColor: "#aabbcc", + }); + }); + + it("copies blurhash and dominantColor when resolving a bare local media ID", async () => { + const providerItem: MediaProviderItem = { + id: "01ABC", + filename: "photo.png", + mimeType: "image/png", + width: 1024, + height: 768, + blurhash: "L6PZfSi_.AyE_3t7t7R**0o#DgR4", + dominantColor: "#112233", + meta: { storageKey: "01ABC.png" }, + }; + const local = mockProvider(providerItem); + + const result = await normalizeMediaValue("01ABC", getProvider({ local })); + + expect(result).toMatchObject({ + provider: "local", + id: "01ABC", + blurhash: "L6PZfSi_.AyE_3t7t7R**0o#DgR4", + dominantColor: "#112233", + }); + }); + + it("preserves caller-supplied blurhash over provider value", async () => { + const providerItem: MediaProviderItem = { + id: "01ABC", + filename: "photo.jpg", + mimeType: "image/jpeg", + width: 1200, + height: 800, + blurhash: "PROVIDER_HASH", + dominantColor: "#000000", + meta: { storageKey: "01ABC.jpg" }, + }; + const local = mockProvider(providerItem); + + const result = await normalizeMediaValue( + { + provider: "local", + id: "01ABC", + blurhash: "CALLER_HASH", + dominantColor: "#ffffff", + }, + getProvider({ local }), + ); + + expect(result!.blurhash).toBe("CALLER_HASH"); + expect(result!.dominantColor).toBe("#ffffff"); + }); + it("handles provider.get throwing gracefully", async () => { const local: MediaProvider = { list: vi.fn().mockResolvedValue({ items: [] }), diff --git a/packages/core/tests/unit/media/placeholder.test.ts b/packages/core/tests/unit/media/placeholder.test.ts index bf89ad1bee..1a780951c5 100644 --- a/packages/core/tests/unit/media/placeholder.test.ts +++ b/packages/core/tests/unit/media/placeholder.test.ts @@ -140,4 +140,33 @@ describe("generatePlaceholder", () => { expect(result).not.toBeNull(); expect(result!.blurhash).toBeTruthy(); }); + + it("returns null when no dimensions can be determined — refuses unbounded decode (OOM guard)", async () => { + // A crafted/truncated PNG whose header image-size cannot parse but a + // decoder might still accept. Without known dimensions the decoded size + // is unbounded, so generation must bail instead of risking OOM. + const unparseable = new Uint8Array([0x00, 0x01, 0x02, 0x03, 0x04, 0x05]); + expect(await generatePlaceholder(unparseable, "image/png")).toBeNull(); + }); + + it("matches case-insensitive MIME types (image/JPEG)", async () => { + const result = await generatePlaceholder(new Uint8Array(JPEG_4x4), "image/JPEG"); + expect(result).not.toBeNull(); + expect(result!.blurhash).toBeTruthy(); + }); + + it("matches MIME types with a parameter suffix (image/jpeg; charset=binary)", async () => { + const result = await generatePlaceholder( + new Uint8Array(JPEG_4x4), + "image/jpeg; charset=binary", + ); + expect(result).not.toBeNull(); + expect(result!.blurhash).toBeTruthy(); + }); + + it("treats image/JPG as jpeg", async () => { + const result = await generatePlaceholder(new Uint8Array(JPEG_4x4), "image/JPG"); + expect(result).not.toBeNull(); + expect(result!.blurhash).toBeTruthy(); + }); }); diff --git a/packages/core/tests/utils/image-fixtures.ts b/packages/core/tests/utils/image-fixtures.ts new file mode 100644 index 0000000000..81bdadbc4a --- /dev/null +++ b/packages/core/tests/utils/image-fixtures.ts @@ -0,0 +1,20 @@ +/** Minimal 4x4 solid red JPEG. */ +export const JPEG_4x4 = new Uint8Array( + Buffer.from( + "/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAMCAgICAgMCAgIDAwMDBAYEBAQEBAgGBgUGCQgKCgkICQkKDA8MCgsOCwkJDRENDg8QEBEQCgwSExIQEw8QEBD/2wBDAQMDAwQDBAgEBAgQCwkLEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBD/wAARCAAEAAQDAREAAhEBAxEB/8QAFAABAAAAAAAAAAAAAAAAAAAACP/EABQQAQAAAAAAAAAAAAAAAAAAAAD/xAAVAQEBAAAAAAAAAAAAAAAAAAAHCf/EABQRAQAAAAAAAAAAAAAAAAAAAAD/2gAMAwEAAhEDEQA/ADoDFU3/2Q==", + "base64", + ), +); + +/** Minimal 4x4 solid red PNG. */ +export const PNG_4x4 = new Uint8Array( + Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAQAAAAEAQMAAACTPww9AAAAIGNIUk0AAHomAACAhAAA+gAAAIDoAAB1MAAA6mAAADqYAAAXcJy6UTwAAAAGUExURf8AAP///0EdNBEAAAABYktHRAH/Ai3eAAAAB3RJTUUH6gIcETMVn1ZhnwAAACV0RVh0ZGF0ZTpjcmVhdGUAMjAyNi0wMi0yOFQxNzo1MToyMCswMDowMJE6EiQAAAAldEVYdGRhdGU6bW9kaWZ5ADIwMjYtMDItMjhUMTc6NTE6MjArMDA6MDDgZ6qYAAAAKHRFWHRkYXRlOnRpbWVzdGFtcAAyMDI2LTAyLTI4VDE3OjUxOjIwKzAwOjAwt3KLRwAAAAtJREFUCNdjYIAAAAAIAAEvIN0xAAAAAElFTkSuQmCC", + "base64", + ), +); + +/** 1x1 transparent GIF — image-size parses dimensions, but blurhash does not support GIF. */ +export const GIF_1x1 = new Uint8Array( + Buffer.from("R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7", "base64"), +);