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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/stream-provider-video-preview.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@emdash-cms/admin": patch
"@emdash-cms/cloudflare": patch
"emdash": patch
---

Fixes media previews for streaming providers such as Cloudflare Stream. Video from these providers now shows its poster thumbnail in the media library grid and list, plays in the detail panel instead of stalling at 0:00, and reports the file size the provider supplies. Also exports `Media` from `emdash/ui`, so frontends can render provider-backed video and audio that `Image` cannot.
16 changes: 15 additions & 1 deletion packages/admin/src/components/MediaDetailPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import * as React from "react";

import { updateMedia, deleteMedia, deleteFromProvider, type MediaItem } from "../lib/api";
import { useStableCallback } from "../lib/hooks";
import { getFileIcon, formatFileSize } from "../lib/media-utils";
import { getFileIcon, formatFileSize, metaPlayback } from "../lib/media-utils";
import { ConfirmDialog } from "./ConfirmDialog";
import { DialogError, getMutationError } from "./DialogError.js";

Expand Down Expand Up @@ -55,6 +55,8 @@ export function MediaDetailPanel({
const isImage = item.mimeType.startsWith("image/");
const isVideo = item.mimeType.startsWith("video/");
const isAudio = item.mimeType.startsWith("audio/");
// Present when the item streams rather than resolving to a playable file.
const playback = metaPlayback(item.meta);
const canEditMetadata = !isProviderAsset && isImage;
const canDelete = !isProviderAsset || Boolean(canDeleteProp);

Expand Down Expand Up @@ -249,7 +251,19 @@ export function MediaDetailPanel({
alt={item.alt || item.filename}
className="max-h-full max-w-full object-contain"
/>
) : isVideo && playback ? (
// Streaming: `item.url` is the poster, not the media.
<video
poster={item.url || undefined}
controls
preload="metadata"
className="max-h-full max-w-full"
>
{playback.hls && <source src={playback.hls} type="application/x-mpegURL" />}
{playback.dash && <source src={playback.dash} type="application/dash+xml" />}
</video>
) : isVideo ? (
// Locally stored video: `item.url` is the file itself.
<video
src={item.url}
controls
Expand Down
11 changes: 5 additions & 6 deletions packages/admin/src/components/MediaLibrary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
getMediaThumbnailUrl,
fallbackToOriginalThumbnail,
MEDIA_THUMBNAIL_WIDTH,
metaNumber,
} from "../lib/media-utils";
import { cn } from "../lib/utils";
import { MediaDetailPanel } from "./MediaDetailPanel";
Expand Down Expand Up @@ -726,8 +727,6 @@ interface ProviderGridItemProps {
}

function ProviderGridItem({ item, selected, onClick, onDimensionsLoaded }: ProviderGridItemProps) {
const isImage = item.mimeType.startsWith("image/");

const handleImageLoad = (e: React.SyntheticEvent<HTMLImageElement>) => {
const img = e.currentTarget;
// Only report if we don't already have dimensions
Expand All @@ -746,7 +745,7 @@ function ProviderGridItem({ item, selected, onClick, onDimensionsLoaded }: Provi
)}
>
<div className="aspect-square">
{isImage && item.previewUrl ? (
{item.previewUrl ? (
<img
src={item.previewUrl}
alt={item.alt || item.filename}
Expand Down Expand Up @@ -826,7 +825,7 @@ interface ProviderListItemProps {

function ProviderListItem({ item, selected, onClick, onDimensionsLoaded }: ProviderListItemProps) {
const { t } = useLingui();
const isImage = item.mimeType.startsWith("image/");
const size = item.size ?? metaNumber(item.meta, "size");

const handleImageLoad = (e: React.SyntheticEvent<HTMLImageElement>) => {
const img = e.currentTarget;
Expand All @@ -845,7 +844,7 @@ function ProviderListItem({ item, selected, onClick, onDimensionsLoaded }: Provi
>
<td className="px-4 py-3">
<div className="h-10 w-10 overflow-hidden rounded">
{isImage && item.previewUrl ? (
{item.previewUrl ? (
<img
src={item.previewUrl}
alt={item.alt || item.filename}
Expand All @@ -862,7 +861,7 @@ function ProviderListItem({ item, selected, onClick, onDimensionsLoaded }: Provi
<td className="px-4 py-3 text-base font-medium leading-5">{item.filename}</td>
<td className="px-4 py-3 text-sm text-kumo-subtle">{item.mimeType}</td>
<td className="px-4 py-3 text-sm text-kumo-subtle tabular-nums">
{item.size ? formatFileSize(item.size) : "—"}
{size ? formatFileSize(size) : "—"}
</td>
<td className="px-4 py-3 text-end">
<span className="text-sm text-kumo-subtle">
Expand Down
39 changes: 38 additions & 1 deletion packages/admin/src/lib/media-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,41 @@ export function metaString(
return typeof value === "string" ? value : undefined;
}

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

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
Comment on lines +21 to +23

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

let me know if we have something for this already ie zod

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

small enough tho, im fine with this approach


/** Streaming playback URLs a provider may expose for a video/audio item. */
export interface MediaPlayback {
hls?: string;
dash?: string;
}

/**
* Read streaming playback URLs from an untyped `meta` bag.
*
* Streaming providers do not expose a single fetchable file URL — Cloudflare
* Stream, for example, reports `meta.playback = { hls, dash }` and uses
* `previewUrl` for the poster thumbnail. Returns undefined when the item has
* no streaming sources (e.g. a plain uploaded MP4, which is playable directly).
*/
export function metaPlayback(meta: Record<string, unknown> | undefined): MediaPlayback | undefined {
const raw = meta?.playback;
if (!isRecord(raw)) return undefined;
const hls = typeof raw.hls === "string" ? raw.hls : undefined;
const dash = typeof raw.dash === "string" ? raw.dash : undefined;
return hls || dash ? { hls, dash } : undefined;
}

export function providerItemToMediaItem(
providerId: string,
item: MediaProviderItem,
Expand All @@ -18,7 +53,9 @@ export function providerItemToMediaItem(
filename: item.filename,
mimeType: item.mimeType,
url: item.previewUrl || "",
size: item.size || 0,
// Providers may report size as a first-class field or stash it in `meta`
// (Cloudflare Stream uses `meta.size`); 0 means "unknown".
size: item.size ?? metaNumber(item.meta, "size") ?? 0,
width: item.width,
height: item.height,
// Prefer first-class fields; some providers stash LQIP in `meta`.
Expand Down
91 changes: 91 additions & 0 deletions packages/admin/tests/components/MediaDetailPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,44 @@ function makePdfItem(overrides: Partial<MediaItem> = {}): MediaItem {
};
}

const STREAM_HLS = "https://customer-abc123.cloudflarestream.com/UID/manifest/video.m3u8";
const STREAM_DASH = "https://customer-abc123.cloudflarestream.com/UID/manifest/video.mpd";
const STREAM_POSTER = "https://customer-abc123.cloudflarestream.com/UID/thumbnails/thumbnail.jpg";

/**
* A Cloudflare Stream item. The distinguishing trait is that `url` is a poster
* image rather than a playable file; the video itself is only reachable through
* `meta.playback`.
*/
function makeStreamItem(overrides: Partial<MediaItem> = {}): MediaItem {
return {
id: "6a4677c7694f6e2e4270540231dd47ff",
filename: "webinar.mp4",
mimeType: "video/mp4",
url: STREAM_POSTER,
size: 75431883,
width: 1280,
height: 720,
createdAt: "2025-01-15T10:30:00Z",
provider: "cloudflare-stream",
meta: { playback: { hls: STREAM_HLS, dash: STREAM_DASH } },
...overrides,
};
}

/** A locally stored video, whose `url` *is* the playable file. */
function makeLocalVideoItem(overrides: Partial<MediaItem> = {}): MediaItem {
return {
id: "media-3",
filename: "clip.mp4",
mimeType: "video/mp4",
url: "https://example.com/clip.mp4",
size: 5242880,
createdAt: "2025-01-15T10:30:00Z",
...overrides,
};
}

function renderPanel(props: Partial<React.ComponentProps<typeof MediaDetailPanel>> = {}) {
const defaultProps: React.ComponentProps<typeof MediaDetailPanel> = {
open: true,
Expand Down Expand Up @@ -497,4 +535,57 @@ describe("MediaDetailPanel file URL", () => {
.not.toBeInTheDocument();
await expect.element(screen.getByText("Uploaded:"), { timeout: 100 }).not.toBeInTheDocument();
});

describe("video preview", () => {
// The dialog may portal outside the render container, so query the document.
const findVideo = () => document.querySelector("video");

it("plays a streaming item's HLS/DASH sources rather than its poster URL", async () => {
const screen = await renderPanel({
item: makeStreamItem(),
providerName: "Cloudflare Stream",
});
await expect.element(screen.getByText("Media Details")).toBeInTheDocument();

const video = findVideo();
expect(video).not.toBeNull();

// Regression: `url` is the thumbnail. Using it as `src` produced a
// player stuck at 0:00 for every Stream asset.
expect(video?.getAttribute("src")).toBeNull();
expect(video?.getAttribute("poster")).toBe(STREAM_POSTER);

const sources = Array.from(document.querySelectorAll("video source"), (s) => ({
src: s.getAttribute("src"),
type: s.getAttribute("type"),
}));
expect(sources).toEqual([
{ src: STREAM_HLS, type: "application/x-mpegURL" },
{ src: STREAM_DASH, type: "application/dash+xml" },
]);
});

it("omits the DASH source when the provider only reports HLS", async () => {
const screen = await renderPanel({
item: makeStreamItem({ meta: { playback: { hls: STREAM_HLS } } }),
providerName: "Cloudflare Stream",
});
await expect.element(screen.getByText("Media Details")).toBeInTheDocument();

const sources = [...document.querySelectorAll("video source")];
expect(sources).toHaveLength(1);
expect(sources[0]?.getAttribute("type")).toBe("application/x-mpegURL");
});

it("plays a locally stored video straight from its file URL", async () => {
const item = makeLocalVideoItem();
const screen = await renderPanel({ item });
await expect.element(screen.getByText("Media Details")).toBeInTheDocument();

const video = findVideo();
expect(video?.getAttribute("src")).toBe(item.url);
// No streaming sources: nothing to negotiate for a plain file.
expect(document.querySelectorAll("video source")).toHaveLength(0);
});
});
});
54 changes: 53 additions & 1 deletion packages/admin/tests/components/MediaLibrary.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import * as React from "react";
import { describe, it, expect, vi, beforeEach } from "vitest";

import { MediaLibrary } from "../../src/components/MediaLibrary";
import type { MediaItem } from "../../src/lib/api";
import type { MediaItem, MediaProviderItem } from "../../src/lib/api";
import { deleteMedia } from "../../src/lib/api";
import { render } from "../utils/render.tsx";

Expand Down Expand Up @@ -498,4 +498,56 @@ describe("MediaLibrary", () => {
expect(screen.getByRole("tab", { name: "List view" }).query()).toBeNull();
});
});

describe("provider items", () => {
const STREAM_SIZE = 75431883;
const STREAM_POSTER =
"https://customer-abc123.cloudflarestream.com/UID/thumbnails/thumbnail.jpg";

// A Cloudflare Stream item: not an image, poster in `previewUrl`, and the
// byte size reported only under `meta`.
function makeStreamProviderItem(overrides: Partial<MediaProviderItem> = {}): MediaProviderItem {
return {
id: "6a4677c7694f6e2e4270540231dd47ff",
filename: "webinar.mp4",
mimeType: "video/mp4",
previewUrl: STREAM_POSTER,
width: 1280,
height: 720,
meta: { size: STREAM_SIZE },
...overrides,
};
}

async function renderStreamTab(item: MediaProviderItem = makeStreamProviderItem()) {
const api = await import("../../src/lib/api");
(api.fetchMediaProviders as any).mockResolvedValue([
{
id: "cloudflare-stream",
name: "Cloudflare Stream",
capabilities: { browse: true, search: false, upload: false, delete: false },
},
]);
(api.fetchProviderMedia as any).mockResolvedValue({ items: [item] });

const screen = await renderLibrary({ items: [] });
await screen.getByRole("tab", { name: "Cloudflare Stream" }).click();
return screen;
}

it("renders the provider poster for an item that is not an image", async () => {
const screen = await renderStreamTab();

const poster = screen.getByAltText("webinar.mp4");
await expect.element(poster).toBeInTheDocument();
expect(poster.element().getAttribute("src")).toBe(STREAM_POSTER);
});

it("shows a size the provider reports only under meta", async () => {
const screen = await renderStreamTab();
await screen.getByRole("tab", { name: "List view" }).click();

await expect.element(screen.getByText("71.9 MB")).toBeInTheDocument();
});
});
});
39 changes: 39 additions & 0 deletions packages/admin/tests/lib/media-playback.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { describe, it, expect } from "vitest";

import { metaPlayback, providerItemToMediaItem } from "../../src/lib/media-utils";

const STREAM_HLS = "https://customer-abc123.cloudflarestream.com/UID/manifest/video.m3u8";
const STREAM_DASH = "https://customer-abc123.cloudflarestream.com/UID/manifest/video.mpd";
const STREAM_POSTER = "https://customer-abc123.cloudflarestream.com/UID/thumbnails/thumbnail.jpg";

describe("metaPlayback", () => {
it("reads the streaming sources Cloudflare Stream reports", () => {
expect(metaPlayback({ playback: { hls: STREAM_HLS, dash: STREAM_DASH } })).toEqual({
hls: STREAM_HLS,
dash: STREAM_DASH,
});
});

it("returns undefined for a plain uploaded file, which is playable directly", () => {
// This is what keeps locally stored video on the plain `src` path.
expect(metaPlayback({ size: 1024 })).toBeUndefined();
expect(metaPlayback(undefined)).toBeUndefined();
});
});

describe("providerItemToMediaItem", () => {
it("falls back to meta.size when the provider reports no top-level size", () => {
// Regression: Stream items displayed "0 B" because only `item.size` was read.
const result = providerItemToMediaItem("cloudflare-stream", {
id: "6a4677c7694f6e2e4270540231dd47ff",
filename: "webinar.mp4",
mimeType: "video/mp4",
previewUrl: STREAM_POSTER,
meta: { size: 75431883, playback: { hls: STREAM_HLS } },
} as never);

expect(result.size).toBe(75431883);
// `meta` must survive the mapping or the detail panel cannot find playback.
expect(metaPlayback(result.meta)).toEqual({ hls: STREAM_HLS });
});
});
4 changes: 3 additions & 1 deletion packages/cloudflare/src/media/stream-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -287,7 +287,9 @@ export const createMediaProvider: CreateMediaProviderFn<CloudflareStreamConfig>
{ src: hlsSrc, type: "application/x-mpegURL" },
...(dashSrc ? [{ src: dashSrc, type: "application/dash+xml" }] : []),
],
poster: toString(value.meta?.thumbnail),
// The Stream thumbnail arrives as `previewUrl`; `meta.thumbnail` is a
// legacy fallback.
poster: value.previewUrl ?? toString(value.meta?.thumbnail),
width: options?.width ?? value.width,
height: options?.height ?? value.height,
controls,
Expand Down
Loading
Loading