Emdash source with visual editor image upload fix
Fixes: 1. media.ts: wrap placeholder generation in try-catch 2. toolbar.ts: check r.ok, display error message in popover
This commit is contained in:
32
packages/core/src/media/index.ts
Normal file
32
packages/core/src/media/index.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Media Provider Exports
|
||||
*
|
||||
* Public API for media providers.
|
||||
*/
|
||||
|
||||
// Types
|
||||
export type {
|
||||
MediaProviderDescriptor,
|
||||
MediaProviderCapabilities,
|
||||
MediaListOptions,
|
||||
MediaListResult,
|
||||
MediaProviderItem,
|
||||
MediaUploadInput,
|
||||
EmbedOptions,
|
||||
EmbedResult,
|
||||
ImageEmbed,
|
||||
VideoEmbed,
|
||||
AudioEmbed,
|
||||
ComponentEmbed,
|
||||
MediaProvider,
|
||||
CreateMediaProviderFn,
|
||||
MediaValue,
|
||||
ThumbnailOptions,
|
||||
} from "./types.js";
|
||||
|
||||
export { mediaItemToValue } from "./types.js";
|
||||
export { normalizeMediaValue } from "./normalize.js";
|
||||
export { generatePlaceholder, type PlaceholderData } from "./placeholder.js";
|
||||
|
||||
// Built-in providers
|
||||
export { localMedia, type LocalMediaConfig } from "./local.js";
|
||||
213
packages/core/src/media/local-runtime.ts
Normal file
213
packages/core/src/media/local-runtime.ts
Normal file
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* Local Media Provider Runtime
|
||||
*
|
||||
* This is the runtime implementation loaded by the entrypoint.
|
||||
* It wraps the existing MediaRepository and storage adapter.
|
||||
*
|
||||
* Note: This provider is special because it needs access to the database
|
||||
* and storage adapter. The createMediaProvider function receives these
|
||||
* via the config object, injected by the runtime.
|
||||
*/
|
||||
|
||||
import type { Kysely } from "kysely";
|
||||
|
||||
import { MediaRepository } from "../database/repositories/media.js";
|
||||
import type { Database } from "../database/types.js";
|
||||
import type { Storage } from "../index.js";
|
||||
import type {
|
||||
CreateMediaProviderFn,
|
||||
MediaProvider,
|
||||
MediaListOptions,
|
||||
MediaProviderItem,
|
||||
MediaValue,
|
||||
EmbedResult,
|
||||
EmbedOptions,
|
||||
} from "./types.js";
|
||||
|
||||
export interface LocalMediaRuntimeConfig {
|
||||
enabled?: boolean;
|
||||
// These are injected by the runtime, not from user config
|
||||
db?: Kysely<Database>;
|
||||
storage?: Storage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the local media provider
|
||||
*/
|
||||
export const createMediaProvider: CreateMediaProviderFn<LocalMediaRuntimeConfig> = (config) => {
|
||||
const { db, storage } = config;
|
||||
|
||||
if (!db) {
|
||||
throw new Error("Local media provider requires database connection");
|
||||
}
|
||||
|
||||
const repo = new MediaRepository(db);
|
||||
|
||||
const provider: MediaProvider = {
|
||||
async list(options: MediaListOptions) {
|
||||
const result = await repo.findMany({
|
||||
cursor: options.cursor,
|
||||
limit: options.limit,
|
||||
mimeType: options.mimeType,
|
||||
// TODO: Add search support when capabilities.search is true
|
||||
});
|
||||
|
||||
return {
|
||||
items: result.items.map((item) => ({
|
||||
id: item.id,
|
||||
filename: item.filename,
|
||||
mimeType: item.mimeType,
|
||||
size: item.size ?? undefined,
|
||||
width: item.width ?? undefined,
|
||||
height: item.height ?? undefined,
|
||||
alt: item.alt ?? undefined,
|
||||
previewUrl: `/_emdash/api/media/file/${item.storageKey}`,
|
||||
meta: {
|
||||
storageKey: item.storageKey,
|
||||
caption: item.caption,
|
||||
blurhash: item.blurhash,
|
||||
dominantColor: item.dominantColor,
|
||||
},
|
||||
})),
|
||||
nextCursor: result.nextCursor,
|
||||
};
|
||||
},
|
||||
|
||||
async get(id: string) {
|
||||
const item = await repo.findById(id);
|
||||
if (!item) return null;
|
||||
|
||||
return {
|
||||
id: item.id,
|
||||
filename: item.filename,
|
||||
mimeType: item.mimeType,
|
||||
size: item.size ?? undefined,
|
||||
width: item.width ?? undefined,
|
||||
height: item.height ?? undefined,
|
||||
alt: item.alt ?? undefined,
|
||||
previewUrl: `/_emdash/api/media/file/${item.storageKey}`,
|
||||
meta: {
|
||||
storageKey: item.storageKey,
|
||||
caption: item.caption,
|
||||
blurhash: item.blurhash,
|
||||
dominantColor: item.dominantColor,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
async upload(_input) {
|
||||
if (!storage) {
|
||||
throw new Error("Storage not configured for local media provider");
|
||||
}
|
||||
|
||||
// This is handled by the existing media upload endpoint
|
||||
// The provider interface is used by external providers
|
||||
// For local, we delegate to the existing system
|
||||
throw new Error("Local upload should use /_emdash/api/media endpoint");
|
||||
},
|
||||
|
||||
async delete(id: string) {
|
||||
const item = await repo.findById(id);
|
||||
if (!item) return;
|
||||
|
||||
// Delete from storage if available
|
||||
if (storage) {
|
||||
try {
|
||||
await storage.delete(item.storageKey);
|
||||
} catch {
|
||||
// Ignore storage deletion errors
|
||||
}
|
||||
}
|
||||
|
||||
await repo.delete(id);
|
||||
},
|
||||
|
||||
getEmbed(value: MediaValue, _options?: EmbedOptions): EmbedResult {
|
||||
const storageKey =
|
||||
typeof value.meta?.storageKey === "string" ? value.meta.storageKey : value.id;
|
||||
const src = `/_emdash/api/media/file/${storageKey}`;
|
||||
const mimeType = value.mimeType || "";
|
||||
|
||||
// Determine embed type based on MIME type
|
||||
if (mimeType.startsWith("image/")) {
|
||||
return {
|
||||
type: "image",
|
||||
src,
|
||||
width: value.width,
|
||||
height: value.height,
|
||||
alt: value.alt,
|
||||
};
|
||||
}
|
||||
|
||||
if (mimeType.startsWith("video/")) {
|
||||
return {
|
||||
type: "video",
|
||||
src,
|
||||
width: value.width,
|
||||
height: value.height,
|
||||
controls: true,
|
||||
preload: "metadata",
|
||||
};
|
||||
}
|
||||
|
||||
if (mimeType.startsWith("audio/")) {
|
||||
return {
|
||||
type: "audio",
|
||||
src,
|
||||
controls: true,
|
||||
preload: "metadata",
|
||||
};
|
||||
}
|
||||
|
||||
// Fallback: treat as image (for unknown types)
|
||||
return {
|
||||
type: "image",
|
||||
src,
|
||||
width: value.width,
|
||||
height: value.height,
|
||||
alt: value.alt,
|
||||
};
|
||||
},
|
||||
|
||||
getThumbnailUrl(id: string, _mimeType?: string) {
|
||||
// For local media, return the file URL
|
||||
return `/_emdash/api/media/file/${id}`;
|
||||
},
|
||||
};
|
||||
|
||||
return provider;
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper to convert a MediaRepository item to MediaProviderItem
|
||||
*/
|
||||
export function repoItemToProviderItem(item: {
|
||||
id: string;
|
||||
filename: string;
|
||||
mimeType: string;
|
||||
size: number | null;
|
||||
width: number | null;
|
||||
height: number | null;
|
||||
alt: string | null;
|
||||
caption: string | null;
|
||||
storageKey: string;
|
||||
blurhash: string | null;
|
||||
dominantColor: string | null;
|
||||
}): MediaProviderItem {
|
||||
return {
|
||||
id: item.id,
|
||||
filename: item.filename,
|
||||
mimeType: item.mimeType,
|
||||
size: item.size ?? undefined,
|
||||
width: item.width ?? undefined,
|
||||
height: item.height ?? undefined,
|
||||
alt: item.alt ?? undefined,
|
||||
previewUrl: `/_emdash/api/media/file/${item.storageKey}`,
|
||||
meta: {
|
||||
storageKey: item.storageKey,
|
||||
caption: item.caption,
|
||||
blurhash: item.blurhash,
|
||||
dominantColor: item.dominantColor,
|
||||
},
|
||||
};
|
||||
}
|
||||
46
packages/core/src/media/local.ts
Normal file
46
packages/core/src/media/local.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Local Media Provider
|
||||
*
|
||||
* The built-in media provider that wraps the current storage adapter and media database.
|
||||
* This is the default provider and is available unless explicitly disabled.
|
||||
*/
|
||||
|
||||
import type { MediaProviderDescriptor } from "./types.js";
|
||||
|
||||
export interface LocalMediaConfig {
|
||||
/** Whether the local provider is enabled (default true) */
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Local media provider configuration
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { localMedia } from "emdash/media";
|
||||
*
|
||||
* emdash({
|
||||
* mediaProviders: [
|
||||
* localMedia(), // Uses defaults
|
||||
* // or: localMedia({ enabled: false }) to disable
|
||||
* ],
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
export function localMedia(config: LocalMediaConfig = {}): MediaProviderDescriptor {
|
||||
return {
|
||||
id: "local",
|
||||
name: "Library",
|
||||
icon: "📁",
|
||||
entrypoint: "emdash/media/local-runtime",
|
||||
capabilities: {
|
||||
browse: true,
|
||||
search: false, // TODO: Add search support
|
||||
upload: true,
|
||||
delete: true,
|
||||
},
|
||||
config: {
|
||||
enabled: config.enabled ?? true,
|
||||
},
|
||||
};
|
||||
}
|
||||
190
packages/core/src/media/normalize.ts
Normal file
190
packages/core/src/media/normalize.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* Media Value Normalization
|
||||
*
|
||||
* Normalizes media field values into a consistent shape regardless of
|
||||
* creation path (seed scripts, media picker, WP import, URL input).
|
||||
*
|
||||
* Called at content create/update time when a media provider is available,
|
||||
* filling in missing dimensions, storageKey, mimeType, and filename from
|
||||
* the provider's `get()` method.
|
||||
*/
|
||||
|
||||
import type { MediaProvider, MediaProviderItem, MediaValue } from "./types.js";
|
||||
|
||||
export const INTERNAL_MEDIA_PREFIX = "/_emdash/api/media/file/";
|
||||
const URL_PATTERN = /^https?:\/\//;
|
||||
|
||||
/**
|
||||
* Normalize a media field value into a consistent MediaValue shape.
|
||||
*
|
||||
* - `null`/`undefined` → `null`
|
||||
* - Bare URL string → `{ provider: "external", id: "", src: url }`
|
||||
* - Bare internal media URL → resolved via local provider's `get()`
|
||||
* - Object with `provider` + `id` → enriched with missing fields from provider
|
||||
*/
|
||||
export async function normalizeMediaValue(
|
||||
value: unknown,
|
||||
getProvider: (id: string) => MediaProvider | undefined,
|
||||
): Promise<MediaValue | null> {
|
||||
if (value == null) return null;
|
||||
|
||||
// Bare string URL
|
||||
if (typeof value === "string") {
|
||||
return normalizeStringUrl(value, getProvider);
|
||||
}
|
||||
|
||||
// Not an object — can't normalize
|
||||
if (!isRecord(value)) return null;
|
||||
|
||||
// Must have at least an id to be a valid media value
|
||||
if (!("id" in value) && !("src" in value)) return null;
|
||||
|
||||
const provider = (typeof value.provider === "string" ? value.provider : undefined) || "local";
|
||||
const id = typeof value.id === "string" ? value.id : "";
|
||||
|
||||
// External URLs — return as-is, no server-side dimension detection
|
||||
if (provider === "external") {
|
||||
return recordToMediaValue(value);
|
||||
}
|
||||
|
||||
// Build the base value from the input
|
||||
const result: MediaValue = { ...recordToMediaValue(value), provider };
|
||||
|
||||
// For local media, strip `src` — it's derived at display time from storageKey
|
||||
if (provider === "local") {
|
||||
delete result.src;
|
||||
}
|
||||
|
||||
// Determine if we need to call the provider
|
||||
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;
|
||||
|
||||
if (!needsLookup || !id) return result;
|
||||
|
||||
// Try to enrich from provider
|
||||
const mediaProvider = getProvider(provider);
|
||||
if (!mediaProvider?.get) return result;
|
||||
|
||||
let providerItem: MediaProviderItem | null;
|
||||
try {
|
||||
providerItem = await mediaProvider.get(id);
|
||||
} catch {
|
||||
return result;
|
||||
}
|
||||
|
||||
if (!providerItem) return result;
|
||||
|
||||
return mergeProviderData(result, providerItem);
|
||||
}
|
||||
|
||||
function normalizeStringUrl(
|
||||
url: string,
|
||||
getProvider: (id: string) => MediaProvider | undefined,
|
||||
): Promise<MediaValue | null> {
|
||||
// Internal media URL — try to resolve via local provider
|
||||
if (url.startsWith(INTERNAL_MEDIA_PREFIX)) {
|
||||
return resolveInternalUrl(url, getProvider);
|
||||
}
|
||||
|
||||
// External HTTP(S) URL
|
||||
if (URL_PATTERN.test(url)) {
|
||||
return Promise.resolve({
|
||||
provider: "external",
|
||||
id: "",
|
||||
src: url,
|
||||
});
|
||||
}
|
||||
|
||||
// Unrecognized string — treat as external
|
||||
return Promise.resolve({
|
||||
provider: "external",
|
||||
id: "",
|
||||
src: url,
|
||||
});
|
||||
}
|
||||
|
||||
async function resolveInternalUrl(
|
||||
url: string,
|
||||
getProvider: (id: string) => MediaProvider | undefined,
|
||||
): Promise<MediaValue> {
|
||||
const storageKey = url.slice(INTERNAL_MEDIA_PREFIX.length);
|
||||
const localProvider = getProvider("local");
|
||||
|
||||
if (!localProvider?.get) {
|
||||
return { provider: "external", id: "", src: url };
|
||||
}
|
||||
|
||||
let item: MediaProviderItem | null;
|
||||
try {
|
||||
item = await localProvider.get(storageKey);
|
||||
} catch {
|
||||
return { provider: "external", id: "", src: url };
|
||||
}
|
||||
|
||||
if (!item) {
|
||||
return { provider: "external", id: "", src: url };
|
||||
}
|
||||
|
||||
return {
|
||||
provider: "local",
|
||||
id: item.id,
|
||||
filename: item.filename,
|
||||
mimeType: item.mimeType,
|
||||
width: item.width,
|
||||
height: item.height,
|
||||
alt: item.alt,
|
||||
meta: item.meta,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge provider data into an existing MediaValue, preserving caller-supplied fields.
|
||||
* Caller `alt` takes priority over provider `alt` (per-usage, not per-image).
|
||||
*/
|
||||
function mergeProviderData(existing: MediaValue, item: MediaProviderItem): MediaValue {
|
||||
const result = { ...existing };
|
||||
|
||||
// Fill missing dimensions
|
||||
if (result.width == null && item.width != null) result.width = item.width;
|
||||
if (result.height == null && item.height != null) result.height = item.height;
|
||||
|
||||
// Fill missing file info
|
||||
if (!result.filename && item.filename) result.filename = item.filename;
|
||||
if (!result.mimeType && item.mimeType) result.mimeType = item.mimeType;
|
||||
|
||||
// Fill missing alt (provider alt is fallback, not override)
|
||||
if (!result.alt && item.alt) result.alt = item.alt;
|
||||
|
||||
// Fill missing meta (merge, don't replace)
|
||||
if (item.meta) {
|
||||
result.meta = { ...item.meta, ...result.meta };
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract known MediaValue fields from a runtime-checked record.
|
||||
* Avoids unsafe `as MediaValue` cast by reading each property explicitly.
|
||||
*/
|
||||
function recordToMediaValue(obj: Record<string, unknown>): MediaValue {
|
||||
const result: MediaValue = {
|
||||
id: typeof obj.id === "string" ? obj.id : "",
|
||||
};
|
||||
if (typeof obj.provider === "string") result.provider = obj.provider;
|
||||
if (typeof obj.src === "string") result.src = obj.src;
|
||||
if (typeof obj.previewUrl === "string") result.previewUrl = obj.previewUrl;
|
||||
if (typeof obj.filename === "string") result.filename = obj.filename;
|
||||
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.alt === "string") result.alt = obj.alt;
|
||||
if (isRecord(obj.meta)) result.meta = obj.meta;
|
||||
return result;
|
||||
}
|
||||
181
packages/core/src/media/placeholder.ts
Normal file
181
packages/core/src/media/placeholder.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* Image Placeholder Generation
|
||||
*
|
||||
* Generates blurhash and dominant color from image buffers for LQIP support.
|
||||
* Decodes images via jpeg-js (pure JS) and upng-js (pure JS, uses pako for
|
||||
* deflate). No Node-specific dependencies — works in Workers and Node SSR.
|
||||
*/
|
||||
|
||||
import { encode } from "blurhash";
|
||||
import { imageSize } from "image-size";
|
||||
|
||||
export interface PlaceholderData {
|
||||
blurhash: string;
|
||||
dominantColor: string;
|
||||
}
|
||||
|
||||
const SUPPORTED_TYPES: Record<string, "jpeg" | "png"> = {
|
||||
"image/jpeg": "jpeg",
|
||||
"image/jpg": "jpeg",
|
||||
"image/png": "png",
|
||||
};
|
||||
|
||||
/** Max width for blurhash input. Encode is O(w*h*components), so downsample first. */
|
||||
const MAX_ENCODE_WIDTH = 32;
|
||||
|
||||
/** Max decoded RGBA size (32 MB). Images exceeding this skip placeholder generation. */
|
||||
const MAX_DECODED_BYTES = 32 * 1024 * 1024;
|
||||
|
||||
interface DecodedImage {
|
||||
width: number;
|
||||
height: number;
|
||||
data: Uint8Array;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a JPEG buffer into raw RGBA pixel data.
|
||||
*/
|
||||
async function decodeJpeg(buffer: Uint8Array): Promise<DecodedImage> {
|
||||
const { decode } = await import("jpeg-js");
|
||||
const result = decode(buffer, { useTArray: true });
|
||||
return { width: result.width, height: result.height, data: result.data };
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a PNG buffer into raw RGBA pixel data.
|
||||
* Uses upng-js (pure JS with pako deflate) — no Node zlib dependency.
|
||||
*/
|
||||
async function decodePng(buffer: Uint8Array): Promise<DecodedImage> {
|
||||
// @ts-expect-error -- upng-js has no type declarations
|
||||
const UPNG = (await import("upng-js")).default;
|
||||
const img = UPNG.decode(buffer.buffer);
|
||||
// toRGBA8 returns an array of frames; take the first frame
|
||||
const frames: ArrayBuffer[] = UPNG.toRGBA8(img);
|
||||
const rgba = new Uint8Array(frames[0]);
|
||||
return { width: img.width, height: img.height, data: rgba };
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the dominant color from RGBA pixel data.
|
||||
* Simple average of all non-transparent pixels.
|
||||
*/
|
||||
function extractDominantColor(data: Uint8Array, width: number, height: number): string {
|
||||
let r = 0;
|
||||
let g = 0;
|
||||
let b = 0;
|
||||
let count = 0;
|
||||
|
||||
const len = width * height * 4;
|
||||
for (let i = 0; i < len; i += 4) {
|
||||
const a = data[i + 3];
|
||||
if (a < 128) continue; // skip mostly-transparent pixels
|
||||
r += data[i];
|
||||
g += data[i + 1];
|
||||
b += data[i + 2];
|
||||
count++;
|
||||
}
|
||||
|
||||
if (count === 0) return "rgb(0,0,0)";
|
||||
|
||||
const avgR = Math.round(r / count);
|
||||
const avgG = Math.round(g / count);
|
||||
const avgB = Math.round(b / count);
|
||||
return `rgb(${avgR},${avgG},${avgB})`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read image dimensions from headers without decoding pixel data.
|
||||
*/
|
||||
function getImageDimensions(buffer: Uint8Array): { width: number; height: number } | null {
|
||||
try {
|
||||
const result = imageSize(buffer);
|
||||
if (result.width != null && result.height != null) {
|
||||
return { width: result.width, height: result.height };
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export async function generatePlaceholder(
|
||||
buffer: Uint8Array,
|
||||
mimeType: string,
|
||||
dimensions?: { width: number; height: number },
|
||||
): Promise<PlaceholderData | null> {
|
||||
const format = SUPPORTED_TYPES[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) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const imageData = format === "jpeg" ? await decodeJpeg(buffer) : await decodePng(buffer);
|
||||
const { width, height, data } = imageData;
|
||||
|
||||
if (width === 0 || height === 0) return null;
|
||||
|
||||
// Downsample for blurhash encoding if needed
|
||||
let encodePixels: Uint8ClampedArray;
|
||||
let encodeWidth: number;
|
||||
let encodeHeight: number;
|
||||
|
||||
if (width > MAX_ENCODE_WIDTH) {
|
||||
const scale = MAX_ENCODE_WIDTH / width;
|
||||
encodeWidth = MAX_ENCODE_WIDTH;
|
||||
encodeHeight = Math.max(1, Math.round(height * scale));
|
||||
encodePixels = downsample(data, width, height, encodeWidth, encodeHeight);
|
||||
} else {
|
||||
encodeWidth = width;
|
||||
encodeHeight = height;
|
||||
encodePixels = new Uint8ClampedArray(data.buffer, data.byteOffset, data.byteLength);
|
||||
}
|
||||
|
||||
const blurhash = encode(encodePixels, encodeWidth, encodeHeight, 4, 3);
|
||||
const dominantColor = extractDominantColor(data, width, height);
|
||||
|
||||
return { blurhash, dominantColor };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Nearest-neighbor downsample of RGBA pixel data.
|
||||
*/
|
||||
function downsample(
|
||||
src: Uint8Array,
|
||||
srcW: number,
|
||||
srcH: number,
|
||||
dstW: number,
|
||||
dstH: number,
|
||||
): Uint8ClampedArray {
|
||||
const dst = new Uint8ClampedArray(dstW * dstH * 4);
|
||||
|
||||
for (let y = 0; y < dstH; y++) {
|
||||
const srcY = Math.floor((y * srcH) / dstH);
|
||||
for (let x = 0; x < dstW; x++) {
|
||||
const srcX = Math.floor((x * srcW) / dstW);
|
||||
const srcIdx = (srcY * srcW + srcX) * 4;
|
||||
const dstIdx = (y * dstW + x) * 4;
|
||||
dst[dstIdx] = src[srcIdx]!;
|
||||
dst[dstIdx + 1] = src[srcIdx + 1]!;
|
||||
dst[dstIdx + 2] = src[srcIdx + 2]!;
|
||||
dst[dstIdx + 3] = src[srcIdx + 3]!;
|
||||
}
|
||||
}
|
||||
|
||||
return dst;
|
||||
}
|
||||
78
packages/core/src/media/provider-loader.ts
Normal file
78
packages/core/src/media/provider-loader.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* Media Provider Loader
|
||||
*
|
||||
* Lazy-loads media providers from virtual module for frontend rendering.
|
||||
* Similar pattern to getDb() - providers are loaded on first use and cached.
|
||||
*
|
||||
* This allows EmDashMedia component to render media without requiring
|
||||
* the full EmDash runtime to be initialized.
|
||||
*/
|
||||
|
||||
import type { MediaProvider, MediaProviderCapabilities } from "./types.js";
|
||||
|
||||
/**
|
||||
* Media provider entry from virtual module
|
||||
*/
|
||||
interface MediaProviderEntry {
|
||||
id: string;
|
||||
name: string;
|
||||
icon?: string;
|
||||
capabilities: MediaProviderCapabilities;
|
||||
createProvider: (ctx: Record<string, unknown>) => MediaProvider;
|
||||
}
|
||||
|
||||
// Cached provider entries from virtual module
|
||||
let virtualMediaProviders: MediaProviderEntry[] | undefined;
|
||||
|
||||
// Cached provider instances (shared across calls)
|
||||
const mediaProviderInstances = new Map<string, MediaProvider>();
|
||||
|
||||
/**
|
||||
* Load media providers from virtual module
|
||||
*/
|
||||
async function loadMediaProviders(): Promise<void> {
|
||||
if (virtualMediaProviders === undefined) {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore - virtual module
|
||||
const providersModule = await import("virtual:emdash/media-providers");
|
||||
virtualMediaProviders = providersModule.mediaProviders || [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a media provider by ID.
|
||||
*
|
||||
* Used by EmDashMedia component for frontend rendering.
|
||||
* Providers are lazy-loaded from virtual module and cached.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const provider = await getMediaProvider("cloudflare-images");
|
||||
* if (provider) {
|
||||
* const embed = provider.getEmbed(mediaValue, { width: 800 });
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export async function getMediaProvider(providerId: string): Promise<MediaProvider | undefined> {
|
||||
// Check cache first
|
||||
const cached = mediaProviderInstances.get(providerId);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Load media providers from virtual module
|
||||
await loadMediaProviders();
|
||||
|
||||
// Find the provider entry
|
||||
const entry = virtualMediaProviders?.find((p) => p.id === providerId);
|
||||
if (!entry) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Create the provider instance
|
||||
// For providers that don't need db/storage (like Cloudflare Images),
|
||||
// they'll get empty context and use env vars directly
|
||||
const provider = entry.createProvider({});
|
||||
mediaProviderInstances.set(providerId, provider);
|
||||
return provider;
|
||||
}
|
||||
32
packages/core/src/media/thumbnail.ts
Normal file
32
packages/core/src/media/thumbnail.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Thumbnail sizing for client-side placeholder generation.
|
||||
*
|
||||
* When the browser generates a thumbnail to send to the server for blurhash
|
||||
* generation, the thumbnail dimensions must fit within a bounded box. Naively
|
||||
* fixing one dimension and deriving the other from the aspect ratio can
|
||||
* explode for extreme aspect ratios (e.g. a 100×840000 image would produce a
|
||||
* 64×537600 canvas), defeating the purpose of the thumbnail.
|
||||
*/
|
||||
|
||||
/** Max dimension (px) for client-generated upload thumbnails. */
|
||||
export const THUMBNAIL_MAX_DIMENSION = 64;
|
||||
|
||||
/**
|
||||
* Compute thumbnail dimensions that fit within a THUMBNAIL_MAX_DIMENSION box,
|
||||
* preserving aspect ratio. Both output dimensions are clamped to at least 1.
|
||||
* Never upscales (scale is capped at 1).
|
||||
*/
|
||||
export function computeThumbnailSize(
|
||||
width: number,
|
||||
height: number,
|
||||
): { width: number; height: number } {
|
||||
if (width <= 0 || height <= 0) {
|
||||
return { width: 1, height: 1 };
|
||||
}
|
||||
const maxDim = Math.max(width, height);
|
||||
const scale = Math.min(1, THUMBNAIL_MAX_DIMENSION / maxDim);
|
||||
return {
|
||||
width: Math.max(1, Math.round(width * scale)),
|
||||
height: Math.max(1, Math.round(height * scale)),
|
||||
};
|
||||
}
|
||||
279
packages/core/src/media/types.ts
Normal file
279
packages/core/src/media/types.ts
Normal file
@@ -0,0 +1,279 @@
|
||||
/**
|
||||
* Media Provider Types
|
||||
*
|
||||
* Media providers are pluggable sources for browsing, uploading, and embedding media.
|
||||
* They enable integration with external services (Unsplash, Cloudinary, Mux, etc.)
|
||||
* alongside the built-in local media library.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Serializable media provider configuration descriptor
|
||||
* Returned by provider config functions (e.g., unsplash(), mux())
|
||||
*/
|
||||
export interface MediaProviderDescriptor<TConfig = Record<string, unknown>> {
|
||||
/** Unique identifier, used in MediaValue.provider */
|
||||
id: string;
|
||||
|
||||
/** Display name for admin UI */
|
||||
name: string;
|
||||
|
||||
/** Icon for tab UI (emoji or URL) */
|
||||
icon?: string;
|
||||
|
||||
/** Module path exporting createMediaProvider function */
|
||||
entrypoint: string;
|
||||
|
||||
/** Optional React component module for custom admin UI */
|
||||
adminModule?: string;
|
||||
|
||||
/** Capability flags determine UI behavior */
|
||||
capabilities: MediaProviderCapabilities;
|
||||
|
||||
/** Serializable config passed to createMediaProvider at runtime */
|
||||
config: TConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider capabilities determine what UI elements to show
|
||||
*/
|
||||
export interface MediaProviderCapabilities {
|
||||
/** Can list/browse media */
|
||||
browse: boolean;
|
||||
/** Supports text search */
|
||||
search: boolean;
|
||||
/** Can upload new media */
|
||||
upload: boolean;
|
||||
/** Can delete media */
|
||||
delete: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for listing media
|
||||
*/
|
||||
export interface MediaListOptions {
|
||||
/** Pagination cursor */
|
||||
cursor?: string;
|
||||
/** Max items to return (default 20) */
|
||||
limit?: number;
|
||||
/** Search query (if capabilities.search is true) */
|
||||
query?: string;
|
||||
/** Filter by MIME type prefix, e.g., "image/", "video/" */
|
||||
mimeType?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result from listing media
|
||||
*/
|
||||
export interface MediaListResult {
|
||||
items: MediaProviderItem[];
|
||||
nextCursor?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A media item as returned by a provider
|
||||
* This is the provider's view of the item, before it's selected
|
||||
*/
|
||||
export interface MediaProviderItem {
|
||||
/** Provider-specific ID */
|
||||
id: string;
|
||||
/** Original filename */
|
||||
filename: string;
|
||||
/** MIME type */
|
||||
mimeType: string;
|
||||
/** File size in bytes (if known) */
|
||||
size?: number;
|
||||
/** Dimensions (for images/video) */
|
||||
width?: number;
|
||||
height?: number;
|
||||
/** Accessibility text */
|
||||
alt?: string;
|
||||
/** Preview URL for admin UI thumbnail */
|
||||
previewUrl?: string;
|
||||
/** Provider-specific metadata */
|
||||
meta?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Input for uploading media
|
||||
*/
|
||||
export interface MediaUploadInput {
|
||||
file: File;
|
||||
filename: string;
|
||||
alt?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for generating embed
|
||||
*/
|
||||
export interface EmbedOptions {
|
||||
/** Desired width (provider may use for optimization) */
|
||||
width?: number;
|
||||
/** Desired height */
|
||||
height?: number;
|
||||
/** Image format preference */
|
||||
format?: "webp" | "avif" | "jpeg" | "png" | "auto";
|
||||
}
|
||||
|
||||
/**
|
||||
* Embed result types
|
||||
*/
|
||||
export type EmbedResult = ImageEmbed | VideoEmbed | AudioEmbed | ComponentEmbed;
|
||||
|
||||
export interface ImageEmbed {
|
||||
type: "image";
|
||||
src: string;
|
||||
srcset?: string;
|
||||
sizes?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
alt?: string;
|
||||
/** Base URL without transforms, for responsive image generation */
|
||||
cdnBaseUrl?: string;
|
||||
/** For providers with URL-based transforms (Cloudinary, imgix) */
|
||||
getSrc?: (opts: { width?: number; height?: number; format?: string }) => string;
|
||||
}
|
||||
|
||||
export interface VideoEmbed {
|
||||
type: "video";
|
||||
/** Single source URL */
|
||||
src?: string;
|
||||
/** Multiple sources for format fallback */
|
||||
sources?: Array<{ src: string; type: string }>;
|
||||
/** Poster/thumbnail image */
|
||||
poster?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
/** Player controls */
|
||||
controls?: boolean;
|
||||
autoplay?: boolean;
|
||||
muted?: boolean;
|
||||
loop?: boolean;
|
||||
playsinline?: boolean;
|
||||
preload?: "none" | "metadata" | "auto";
|
||||
crossorigin?: "anonymous" | "use-credentials";
|
||||
}
|
||||
|
||||
export interface AudioEmbed {
|
||||
type: "audio";
|
||||
src?: string;
|
||||
sources?: Array<{ src: string; type: string }>;
|
||||
controls?: boolean;
|
||||
autoplay?: boolean;
|
||||
muted?: boolean;
|
||||
loop?: boolean;
|
||||
preload?: "none" | "metadata" | "auto";
|
||||
}
|
||||
|
||||
export interface ComponentEmbed {
|
||||
type: "component";
|
||||
/** Package to import from, e.g., "@mux/player-react" */
|
||||
package: string;
|
||||
/** Named export (default export if not specified) */
|
||||
export?: string;
|
||||
/** Props to pass to the component */
|
||||
props: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for thumbnail generation
|
||||
*/
|
||||
export interface ThumbnailOptions {
|
||||
/** Desired width */
|
||||
width?: number;
|
||||
/** Desired height */
|
||||
height?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runtime media provider interface
|
||||
* Implemented by provider entrypoints
|
||||
*/
|
||||
export interface MediaProvider {
|
||||
/**
|
||||
* List/search media items
|
||||
*/
|
||||
list(options: MediaListOptions): Promise<MediaListResult>;
|
||||
|
||||
/**
|
||||
* Get a single item by ID (optional, for refresh/validation)
|
||||
*/
|
||||
get?(id: string): Promise<MediaProviderItem | null>;
|
||||
|
||||
/**
|
||||
* Upload new media (if capabilities.upload is true)
|
||||
*/
|
||||
upload?(input: MediaUploadInput): Promise<MediaProviderItem>;
|
||||
|
||||
/**
|
||||
* Delete media (if capabilities.delete is true)
|
||||
*/
|
||||
delete?(id: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Get embed information for rendering this media item
|
||||
* Called at runtime when rendering content
|
||||
*/
|
||||
getEmbed(value: MediaValue, options?: EmbedOptions): Promise<EmbedResult> | EmbedResult;
|
||||
|
||||
/**
|
||||
* Get a thumbnail URL for admin display
|
||||
* For images: returns a resized image URL
|
||||
* For videos: returns a poster/thumbnail URL
|
||||
*/
|
||||
getThumbnailUrl?(id: string, mimeType?: string, options?: ThumbnailOptions): string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Function signature for provider entrypoint modules
|
||||
*/
|
||||
export type CreateMediaProviderFn<TConfig = Record<string, unknown>> = (
|
||||
config: TConfig,
|
||||
) => MediaProvider;
|
||||
|
||||
/**
|
||||
* Media value stored in content fields
|
||||
* This is what gets persisted when media is selected
|
||||
*
|
||||
* For backwards compatibility:
|
||||
* - `provider` defaults to "local" if not specified
|
||||
* - `src` is supported for legacy data or external URLs
|
||||
*/
|
||||
export interface MediaValue {
|
||||
/** Provider ID, e.g., "local", "unsplash", "mux" (defaults to "local") */
|
||||
provider?: string;
|
||||
|
||||
/** Provider-specific item ID */
|
||||
id: string;
|
||||
|
||||
/** Direct URL (for local media or legacy data) */
|
||||
src?: string;
|
||||
|
||||
/** Preview URL for admin display (external providers) */
|
||||
previewUrl?: string;
|
||||
|
||||
/** Cached metadata for display without runtime lookup */
|
||||
filename?: string;
|
||||
mimeType?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
alt?: string;
|
||||
|
||||
/** Provider-specific data needed for embedding */
|
||||
meta?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a MediaProviderItem to a MediaValue for storage
|
||||
*/
|
||||
export function mediaItemToValue(providerId: string, item: MediaProviderItem): MediaValue {
|
||||
return {
|
||||
provider: providerId,
|
||||
id: item.id,
|
||||
filename: item.filename,
|
||||
mimeType: item.mimeType,
|
||||
width: item.width,
|
||||
height: item.height,
|
||||
alt: item.alt,
|
||||
meta: item.meta,
|
||||
};
|
||||
}
|
||||
78
packages/core/src/media/url.ts
Normal file
78
packages/core/src/media/url.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* Public media URL resolution.
|
||||
*
|
||||
* Used at render time by the Image components to decide whether a storage
|
||||
* key should be served from the configured `publicUrl` (R2 custom domain,
|
||||
* S3 CDN) or through the internal `/_emdash/api/media/file/{key}` route.
|
||||
*/
|
||||
import type { Storage } from "../storage/types.js";
|
||||
import { INTERNAL_MEDIA_PREFIX } from "./normalize.js";
|
||||
|
||||
// Keys accepted by the public-URL rewrite: the `{ulid}{ext}` shape produced by
|
||||
// the upload pipeline, with letters, digits, dots, dashes, and underscores.
|
||||
// Slashes, `?`, `#`, and `%` are rejected so attacker-controlled content in a
|
||||
// portable-text `asset.url` cannot traverse or reroute on the CDN origin.
|
||||
const SAFE_STORAGE_KEY = /^[A-Za-z0-9._-]+$/;
|
||||
|
||||
/**
|
||||
* Resolve the public URL for a locally stored media key. Returns an empty
|
||||
* string when no key is given. When a storage adapter is supplied, defers to
|
||||
* `storage.getPublicUrl()`; otherwise returns the internal proxy route.
|
||||
*/
|
||||
export function resolvePublicMediaUrl(
|
||||
storage: Storage | null | undefined,
|
||||
storageKey: string,
|
||||
): string {
|
||||
if (!storageKey) return "";
|
||||
if (storage) return storage.getPublicUrl(storageKey);
|
||||
return `/_emdash/api/media/file/${storageKey}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the `getPublicMediaUrl` closure attached to `Astro.locals.emdash`.
|
||||
* Shared by the anonymous fast path and the full-runtime path in middleware.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export function createPublicMediaUrlResolver(
|
||||
storage: Storage | null | undefined,
|
||||
): (key: string) => string {
|
||||
return (key) => resolvePublicMediaUrl(storage, key);
|
||||
}
|
||||
|
||||
/** Input shape for {@link buildRenderMediaUrl}. */
|
||||
export interface RenderMediaRef {
|
||||
/** Storage key with extension (the canonical shape from the upload pipeline). */
|
||||
storageKey?: string;
|
||||
/** Pre-baked URL (either an internal proxy URL or an external URL). */
|
||||
url?: string;
|
||||
/** Bare media id (ULID without extension); only the internal proxy can look this up. */
|
||||
id?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a render-time media URL. Prefers `storageKey`, then rewrites an
|
||||
* internal `url` via `resolve`, then falls back to the internal proxy for a
|
||||
* bare `id`. External URLs and non-matching internal-looking URLs pass
|
||||
* through untouched. Returns `""` when nothing usable is present.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export function buildRenderMediaUrl(
|
||||
resolve: ((key: string) => string) | undefined,
|
||||
ref: RenderMediaRef,
|
||||
): string {
|
||||
const { storageKey, url, id } = ref;
|
||||
if (storageKey) {
|
||||
return resolve ? resolve(storageKey) : `${INTERNAL_MEDIA_PREFIX}${storageKey}`;
|
||||
}
|
||||
if (url) {
|
||||
if (resolve && url.startsWith(INTERNAL_MEDIA_PREFIX)) {
|
||||
const key = url.slice(INTERNAL_MEDIA_PREFIX.length);
|
||||
if (SAFE_STORAGE_KEY.test(key)) return resolve(key);
|
||||
}
|
||||
return url;
|
||||
}
|
||||
if (id) return `${INTERNAL_MEDIA_PREFIX}${id}`;
|
||||
return "";
|
||||
}
|
||||
Reference in New Issue
Block a user