Merge pull request #3000 from Molecule-AI/rfc-2991-pr-2-video-audio-preview

feat(canvas/chat): inline video + audio HTML5 players (RFC #2991 PR-2)
This commit is contained in:
Hongming Wang 2026-05-05 20:18:36 -07:00 committed by GitHub
commit b530c147de
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 367 additions and 2 deletions

View File

@ -0,0 +1,124 @@
"use client";
// AttachmentAudio — inline native HTML5 <audio controls> player for
// chat attachments (RFC #2991, PR-2).
//
// Same auth + Blob-URL pattern as AttachmentImage / AttachmentVideo.
// Native audio control bar handles play/pause/scrub/volume/download,
// and there's no fullscreen UI to worry about (audio doesn't need
// AttachmentLightbox).
import { useState, useEffect, useRef } from "react";
import type { ChatAttachment } from "./types";
import { isPlatformAttachment, resolveAttachmentHref } from "./uploads";
import { AttachmentChip } from "./AttachmentViews";
interface Props {
workspaceId: string;
attachment: ChatAttachment;
onDownload: (a: ChatAttachment) => void;
tone: "user" | "agent";
}
type FetchState =
| { kind: "idle" }
| { kind: "loading" }
| { kind: "ready"; src: string }
| { kind: "error" };
export function AttachmentAudio({ workspaceId, attachment, onDownload, tone }: Props) {
const [state, setState] = useState<FetchState>({ kind: "idle" });
const blobUrlRef = useRef<string | null>(null);
useEffect(() => {
let cancelled = false;
setState({ kind: "loading" });
if (!isPlatformAttachment(attachment.uri)) {
const href = resolveAttachmentHref(workspaceId, attachment.uri);
if (!cancelled) setState({ kind: "ready", src: href });
return;
}
void (async () => {
try {
const href = resolveAttachmentHref(workspaceId, attachment.uri);
const headers: Record<string, string> = {};
const adminToken = process.env.NEXT_PUBLIC_ADMIN_TOKEN;
if (adminToken) headers["Authorization"] = `Bearer ${adminToken}`;
const slug = getTenantSlug();
if (slug) headers["X-Molecule-Org-Slug"] = slug;
const res = await fetch(href, {
headers,
credentials: "include",
signal: AbortSignal.timeout(60_000),
});
if (!res.ok) {
if (!cancelled) setState({ kind: "error" });
return;
}
const blob = await res.blob();
const url = URL.createObjectURL(blob);
blobUrlRef.current = url;
if (cancelled) {
URL.revokeObjectURL(url);
return;
}
setState({ kind: "ready", src: url });
} catch {
if (!cancelled) setState({ kind: "error" });
}
})();
return () => {
cancelled = true;
if (blobUrlRef.current) {
URL.revokeObjectURL(blobUrlRef.current);
blobUrlRef.current = null;
}
};
}, [workspaceId, attachment.uri]);
if (state.kind === "error") {
return <AttachmentChip attachment={attachment} onDownload={onDownload} tone={tone} />;
}
if (state.kind === "idle" || state.kind === "loading") {
return (
<div
className="rounded-md border border-line/50 bg-surface-card/40 animate-pulse"
style={{ width: 280, height: 40 }}
aria-label={`Loading ${attachment.name}`}
/>
);
}
return (
<div
className={`inline-flex flex-col gap-1 rounded-md border px-2 py-1 ${
tone === "user" ? "border-blue-400/30 bg-accent-strong/10" : "border-line/50 bg-surface-card/40"
}`}
>
{/* Filename label so the user knows what they're hearing
before pressing play. Short, single-line, truncated. */}
<span className="text-[10px] text-ink-mid truncate max-w-[280px]" title={attachment.name}>
{attachment.name}
</span>
<audio
controls
preload="metadata"
src={state.src}
style={{ width: 280, height: 32 }}
onError={() => setState({ kind: "error" })}
>
{attachment.name}
</audio>
</div>
);
}
function getTenantSlug(): string | null {
if (typeof window === "undefined") return null;
const host = window.location.hostname;
const m = host.match(/^([^.]+)\.moleculesai\.app$/);
return m ? m[1] : null;
}

View File

@ -17,6 +17,8 @@
import type { ChatAttachment } from "./types";
import { getAttachmentPreviewKind } from "./preview-kind";
import { AttachmentImage } from "./AttachmentImage";
import { AttachmentVideo } from "./AttachmentVideo";
import { AttachmentAudio } from "./AttachmentAudio";
import { AttachmentChip } from "./AttachmentViews";
interface Props {
@ -43,10 +45,25 @@ export function AttachmentPreview({ workspaceId, attachment, onDownload, tone }:
tone={tone}
/>
);
// PR-2 will add cases for video / audio.
// PR-3 will add cases for pdf / text.
case "video":
return (
<AttachmentVideo
workspaceId={workspaceId}
attachment={attachment}
onDownload={onDownload}
tone={tone}
/>
);
case "audio":
return (
<AttachmentAudio
workspaceId={workspaceId}
attachment={attachment}
onDownload={onDownload}
tone={tone}
/>
);
// PR-3 will add cases for pdf / text.
case "pdf":
case "text":
case "file":

View File

@ -0,0 +1,157 @@
"use client";
// AttachmentVideo — inline native HTML5 <video controls> player for
// chat attachments (RFC #2991, PR-2).
//
// Why HTML5-native (vs custom JS player):
//
// - Browser vendors ship hardware-accelerated decoders, captions,
// and fullscreen UI. We get all of it for free.
// - Native fullscreen via the <video> element's built-in button
// (no AttachmentLightbox needed for video — the browser does it).
// - Mobile-friendly: iOS / Android Safari + Chrome handle the
// pinch + scrub UX the user already knows.
//
// Auth model — identical to AttachmentImage:
// platform-auth URIs need our cookie/token, so we fetch the bytes,
// wrap in a Blob, hand the browser an ObjectURL via <video src=>.
// External (http/https) URIs skip the fetch and use the raw URL.
//
// Memory caveat: a Blob holds the entire video in JS memory until
// the bubble unmounts. For multi-hundred-MB videos this is bad. The
// server caps single-file uploads at 25MB (chat_files.go), so we're
// bounded; if larger files become a real shape, switch to streaming
// via MediaSource or just `<video src=…>` with a credentials-aware
// fetch via service worker. v2 if measured-needed.
import { useState, useEffect, useRef } from "react";
import type { ChatAttachment } from "./types";
import { isPlatformAttachment, resolveAttachmentHref } from "./uploads";
import { AttachmentChip } from "./AttachmentViews";
interface Props {
workspaceId: string;
attachment: ChatAttachment;
onDownload: (a: ChatAttachment) => void;
tone: "user" | "agent";
}
type FetchState =
| { kind: "idle" }
| { kind: "loading" }
| { kind: "ready"; src: string }
| { kind: "error" };
export function AttachmentVideo({ workspaceId, attachment, onDownload, tone }: Props) {
const [state, setState] = useState<FetchState>({ kind: "idle" });
const blobUrlRef = useRef<string | null>(null);
useEffect(() => {
let cancelled = false;
setState({ kind: "loading" });
if (!isPlatformAttachment(attachment.uri)) {
// External video (http/https) — let the browser stream it
// natively without the JS-blob detour.
const href = resolveAttachmentHref(workspaceId, attachment.uri);
if (!cancelled) setState({ kind: "ready", src: href });
return;
}
void (async () => {
try {
const href = resolveAttachmentHref(workspaceId, attachment.uri);
const headers: Record<string, string> = {};
const adminToken = process.env.NEXT_PUBLIC_ADMIN_TOKEN;
if (adminToken) headers["Authorization"] = `Bearer ${adminToken}`;
const slug = getTenantSlug();
if (slug) headers["X-Molecule-Org-Slug"] = slug;
const res = await fetch(href, {
headers,
credentials: "include",
// Videos are larger than images on average; give the request
// more headroom. The server's per-request body cap (50MB) is
// still the actual ceiling.
signal: AbortSignal.timeout(120_000),
});
if (!res.ok) {
if (!cancelled) setState({ kind: "error" });
return;
}
const blob = await res.blob();
const url = URL.createObjectURL(blob);
blobUrlRef.current = url;
if (cancelled) {
URL.revokeObjectURL(url);
return;
}
setState({ kind: "ready", src: url });
} catch {
if (!cancelled) setState({ kind: "error" });
}
})();
return () => {
cancelled = true;
if (blobUrlRef.current) {
URL.revokeObjectURL(blobUrlRef.current);
blobUrlRef.current = null;
}
};
}, [workspaceId, attachment.uri]);
if (state.kind === "error") {
return <AttachmentChip attachment={attachment} onDownload={onDownload} tone={tone} />;
}
if (state.kind === "idle" || state.kind === "loading") {
return (
<div
className="rounded-md border border-line/50 bg-surface-card/40 animate-pulse"
style={{ width: 320, height: 180 }}
aria-label={`Loading ${attachment.name}`}
/>
);
}
return (
<div
className={`inline-block rounded-lg overflow-hidden border ${
tone === "user" ? "border-blue-400/30" : "border-line/50"
}`}
>
<video
controls
// preload="metadata" so the browser fetches just enough to
// show duration + first frame thumbnail without streaming
// the whole file before the user clicks play.
preload="metadata"
// playsInline keeps mobile Safari from auto-fullscreening
// on play; the user can still hit the native fullscreen
// button (or PiP on Chrome) if they want.
playsInline
// Native fullscreen via the <video> control bar; no
// AttachmentLightbox needed for video.
src={state.src}
// Cap thumbnail / inline display so the bubble doesn't blow
// up vertical layout for tall portrait clips. The native
// fullscreen button uses the original aspect ratio.
style={{ maxWidth: 320, maxHeight: 240, display: "block" }}
// Bytes that aren't actually a valid video (corrupt blob,
// wrong Content-Type) fail load → swap to chip.
onError={() => setState({ kind: "error" })}
>
<track kind="captions" />
{attachment.name}
</video>
</div>
);
}
// Internal helper — same shape as AttachmentImage's. Lifted to a
// shared util in PR-2.5 if a third caller needs it (PDF, audio).
function getTenantSlug(): string | null {
if (typeof window === "undefined") return null;
const host = window.location.hostname;
const m = host.match(/^([^.]+)\.moleculesai\.app$/);
return m ? m[1] : null;
}

View File

@ -154,6 +154,73 @@ describe("AttachmentPreview dispatch", () => {
});
});
// ─── PR-2: video / audio dispatch ───────────────────────────────
it("kind=video → renders <video controls> after fetch resolves", async () => {
fetchMock.mockResolvedValue({
ok: true,
blob: async () => new Blob(["fake-mp4"], { type: "video/mp4" }),
});
preview({ uri: "workspace:/workspace/clip.mp4", name: "clip.mp4", mimeType: "video/mp4" });
// Loading placeholder first.
expect(await screen.findByLabelText(/Loading clip\.mp4/i)).toBeTruthy();
// After the blob resolves, a <video> element with controls=true
// is in the DOM. Use a tag query — there's no built-in role for
// <video>, but the element is unambiguous in the bubble.
await waitFor(() => {
const v = document.querySelector("video");
expect(v).not.toBeNull();
// controls attribute pinned — without it the user can't play.
expect(v?.hasAttribute("controls")).toBe(true);
// src is the blob URL we minted.
expect((v as HTMLVideoElement).src).toBe("blob:test-url");
});
// Chip MUST NOT render — proves dispatch routed to video, not file.
expect(screen.queryByTitle(/Download clip\.mp4/i)).toBeNull();
});
it("kind=video fetch fails → falls back to AttachmentChip", async () => {
fetchMock.mockResolvedValue({ ok: false, status: 404 });
preview({ uri: "workspace:/workspace/missing.mp4", name: "missing.mp4", mimeType: "video/mp4" });
await waitFor(() => {
expect(screen.getByTitle(/Download missing\.mp4/i)).toBeTruthy();
});
});
it("kind=video by extension fallback (no mime) → video path", async () => {
fetchMock.mockReturnValue(new Promise(() => {}));
preview({ uri: "workspace:/workspace/recording.webm", name: "recording.webm" });
expect(await screen.findByLabelText(/Loading recording\.webm/i)).toBeTruthy();
});
it("kind=audio → renders <audio controls> with filename label", async () => {
fetchMock.mockResolvedValue({
ok: true,
blob: async () => new Blob(["fake-mp3"], { type: "audio/mpeg" }),
});
preview({ uri: "workspace:/workspace/song.mp3", name: "song.mp3", mimeType: "audio/mpeg" });
await waitFor(() => {
const a = document.querySelector("audio");
expect(a).not.toBeNull();
expect(a?.hasAttribute("controls")).toBe(true);
expect((a as HTMLAudioElement).src).toBe("blob:test-url");
});
// Filename label pinned: helps the user know what they're hearing
// BEFORE pressing play. Multiple matches — `<span>` text and the
// `<audio>`'s fallback `{name}` text node — so getAllByText.
expect(screen.getAllByText("song.mp3").length).toBeGreaterThan(0);
});
it("kind=audio fetch fails → falls back to chip", async () => {
fetchMock.mockResolvedValue({ ok: false, status: 403 });
preview({ uri: "workspace:/workspace/locked.wav", name: "locked.wav", mimeType: "audio/wav" });
await waitFor(() => {
expect(screen.getByTitle(/Download locked\.wav/i)).toBeTruthy();
});
});
// ─── universal-fallback regression ─────────────────────────────────
it("kind=file is the universal fallback for unknown MIME (regression: don't try to preview a zip)", () => {
// Critical safety: agent could attach a misnamed file. Pre-fix
// the chip path was unconditional; we want unknown MIME to