mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-04 11:01:16 +01:00
PAP-10477: render video artifact thumbnails
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
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 })} />,
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in new issue
Block a user