first commit
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";
|
||||
|
||||
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;
|
||||
}
|
||||
150
packages/core/src/media/placeholder.ts
Normal file
150
packages/core/src/media/placeholder.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* 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";
|
||||
|
||||
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;
|
||||
|
||||
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})`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate blurhash and dominant color from an image buffer.
|
||||
* Returns null for non-image MIME types or on failure.
|
||||
*/
|
||||
export async function generatePlaceholder(
|
||||
buffer: Uint8Array,
|
||||
mimeType: string,
|
||||
): Promise<PlaceholderData | null> {
|
||||
const format = SUPPORTED_TYPES[mimeType];
|
||||
if (!format) return null;
|
||||
|
||||
try {
|
||||
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;
|
||||
}
|
||||
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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user