From 3c65f784b640a0012ce4672c1295331d4acd8a0c Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 6 Jun 2026 15:41:56 +0000 Subject: [PATCH] PAP-10477: render video artifact thumbnails Co-Authored-By: Paperclip --- .../artifacts/ArtifactCard.test.tsx | 42 +++++++++++++++++++ ui/src/components/artifacts/ArtifactCard.tsx | 32 +++++++++++++- 2 files changed, 72 insertions(+), 2 deletions(-) diff --git a/ui/src/components/artifacts/ArtifactCard.test.tsx b/ui/src/components/artifacts/ArtifactCard.test.tsx index 2c7503ffd53..5e206763f1a 100644 --- a/ui/src/components/artifacts/ArtifactCard.test.tsx +++ b/ui/src/components/artifacts/ArtifactCard.test.tsx @@ -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( + , + ); + }); + + 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( , diff --git a/ui/src/components/artifacts/ArtifactCard.tsx b/ui/src/components/artifacts/ArtifactCard.tsx index f5f6d0e70ef..61c52b1e800 100644 --- a/ui/src/components/artifacts/ArtifactCard.tsx +++ b/ui/src/components/artifacts/ArtifactCard.tsx @@ -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 ( @@ -61,6 +63,28 @@ function VideoPreview({ artifact }: { artifact: CompanyArtifact }) { ); } + + const markFrameReady = () => setFrameReady(true); + const loadThumbnailFrame = (event: SyntheticEvent) => { + 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) => { + if (thumbnailSeekRequested.current || event.currentTarget.currentTime > 0) { + markFrameReady(); + } + }; + return (