PAP-10477: render video artifact thumbnails

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-06-06 19:33:55 +00:00
1 parent 71a8464fee
commit 3c65f784b6
2 files changed
+72 -2

No files matched your search

@@ -1,4 +1,8 @@
// @vitest-environment jsdom
import type { ReactNode } from "react";
import { flushSync } from "react-dom";
import { createRoot } from "react-dom/client";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it, vi } from "vitest";
@@ -88,6 +92,44 @@ describe("ArtifactCard", () => {
expect(markup).toContain('preload="metadata"');
});
it("seeks video previews after metadata loads so a thumbnail frame is painted", () => {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
flushSync(() => {
root.render(
<ArtifactCard
artifact={makeArtifact({ mediaKind: "video", contentType: "video/mp4", contentPath: "/files/clip.mp4" })}
/>,
);
});
const video = container.querySelector("video") as HTMLVideoElement;
expect(video).not.toBeNull();
expect(video.dataset.frameReady).toBe("false");
Object.defineProperty(video, "readyState", {
configurable: true,
value: HTMLMediaElement.HAVE_METADATA,
});
flushSync(() => {
video.dispatchEvent(new Event("loadedmetadata", { bubbles: true }));
});
expect(video.currentTime).toBe(0.05);
flushSync(() => {
video.dispatchEvent(new Event("seeked", { bubbles: true }));
});
expect(video.dataset.frameReady).toBe("true");
flushSync(() => root.unmount());
container.remove();
});
it("renders a falling-back video placeholder when no content path exists", () => {
const markup = renderToStaticMarkup(
<ArtifactCard artifact={makeArtifact({ mediaKind: "video", contentPath: null })} />,
+30 -2
View File
@@ -1,4 +1,4 @@
import { useState } from "react";
import { type SyntheticEvent, useRef, useState } from "react";
import { Download, ExternalLink, Paperclip, Play } from "lucide-react";
import type { CompanyArtifact } from "@/api/artifacts";
import { Link } from "@/lib/router";
@@ -52,6 +52,8 @@ function ImagePreview({ artifact }: { artifact: CompanyArtifact }) {
function VideoPreview({ artifact }: { artifact: CompanyArtifact }) {
const [errored, setErrored] = useState(false);
const [frameReady, setFrameReady] = useState(false);
const thumbnailSeekRequested = useRef(false);
if (errored || !artifact.contentPath) {
return (
<PreviewFrame className="flex items-center justify-center bg-black/80">
@@ -61,6 +63,28 @@ function VideoPreview({ artifact }: { artifact: CompanyArtifact }) {
</PreviewFrame>
);
}
const markFrameReady = () => setFrameReady(true);
const loadThumbnailFrame = (event: SyntheticEvent<HTMLVideoElement>) => {
if (thumbnailSeekRequested.current) return;
thumbnailSeekRequested.current = true;
const video = event.currentTarget;
const duration = Number.isFinite(video.duration) && video.duration > 0 ? video.duration : 0;
const seekTarget = duration > 0 ? Math.min(0.12, duration / 2) : 0.05;
try {
if (Math.abs(video.currentTime - seekTarget) > 0.001) {
video.currentTime = seekTarget;
}
} catch {
markFrameReady();
}
};
const handleLoadedData = (event: SyntheticEvent<HTMLVideoElement>) => {
if (thumbnailSeekRequested.current || event.currentTarget.currentTime > 0) {
markFrameReady();
}
};
return (
<PreviewFrame className="bg-black">
<video
@@ -68,7 +92,11 @@ function VideoPreview({ artifact }: { artifact: CompanyArtifact }) {
preload="metadata"
muted
playsInline
className="h-full w-full object-contain"
data-frame-ready={frameReady ? "true" : "false"}
className={cn("h-full w-full object-contain transition-opacity", frameReady ? "opacity-100" : "opacity-0")}
onLoadedMetadata={loadThumbnailFrame}
onLoadedData={handleLoadedData}
onSeeked={markFrameReady}
onError={() => setErrored(true)}
/>
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">